You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
While solving AoC Day 4 I ran into the following performance difference:
fnmain(){let input = vec![(2,4,6,8),(2,3,4,5),(5,7,7,9),(2,8,3,7),(6,6,4,6),(2,6,4,8),// .. 500000000 lines of random data, read from disk with real code (~12GB)];// 1761ms on my machinelet _variant_a_result = variant_a(&input);// 656ms on my machinelet _variant_b_result = variant_b(&input);}pubfnvariant_a(input:&[(usize,usize,usize,usize)]) -> usize{
input
.iter().filter(|(a, b, c, d)| a <= c && d <= b || c <= a && b <= d).count()}pubfnvariant_b(input:&[(usize,usize,usize,usize)]) -> usize{
input
.iter().filter(|&&(a, b, c, d)| a <= c && d <= b || c <= a && b <= d).count()}
As you can see, using |&&(a, b, c, d)| on the same closure is 2.6 times faster than |(a, b, c, d)|.
Viewing the compiled code makes this clear: https://rust.godbolt.org/z/oaeaGGPK7
Is this a compiler behavior a developer should know / expect?
Is this a compiler behavior a developer should know / expect?
Strictly answering this question: You are working with &T and then calling .iter(), which creates nested indirections. Yes, the cost of an indirection, especailly nested indirections, is indeed probably something you should be aware of when you are working with plain, "by-value" data, and thus you should probably try to avoid it.
Why is variant_a valid, and why does it create a nested indirection? I sorta understand the non-tuple case described in an old reddit thread, but I don't understand how you can destructure a tuple and still have the wrong level of indirection.
Yes, this code is not unusual and ideally we'd compile it optimally in all cases, but creating indirections is Known to sometimes affect performance. It can sometimes be preferable to e.g. call .into_iter() instead of .iter() for this reason (or do the opposite, for other reasons).
but I don't understand how you can destructure a tuple and still have the wrong level of indirection.
If you destructure some &tuple into (A, B), you will get (&A, &B). This happens due to... you know, I forget exactly which, at this point.
This happens due to... you know, I forget exactly which, at this point.
Match ergonomics. Without it the equivalent code would've been &&(ref a, ref b, ref c, ref d). Of course if one was writing that they would notice that usize is Copy so they can skip the refs, which would happen to solve the slowness issue. (This same logic is how I came up with the fix for OP.) But since we do have match ergonomics, code like OP's is very common, so it would be nice if it didn't have a penalty.
The copying isn't actually necessary to fix it. All LLVM needs to know is that it's a good idea to pre-read them, so this (variant C) also fixes it: https://rust.godbolt.org/z/W4hqEjj6c
By using non-short-circuiting operations LLVM doesn't need to worry about "is it ok to read that?" because it'll have to read everything. And then the right thing happens.
To further emphasize this, note that this (variant D) does not fix the problem, despite copying-via-dereference all the values, as the short-circuiting logic still means it's not "obviously" reading everything: https://rust.godbolt.org/z/4zPfMejcP
I wonder if it would fix it if optimized builds always did a copy-read of all &s to small copy types as soon as they were initialized, and let LLVM just optimize them away if they aren't actually CSE'd into something useful...
pubfnvariant_c_prime(input:&[(usize,usize,usize,usize)]) -> usize{
input
.iter().filter(|(a, b, c, d)| ((d <= b)&(a <= c)) || ((c <= a) && (b <= d)))// ^ just this is enough.count()}
Because that one & is enough to say that it'll read all 4 values, and thus it knows the rest of the expression is side-effect-free and doesn't need to preserve the short-circuiting.
One could also consider looking at it like this, if I got my truth tables right:
I wonder if it would fix it if optimized builds always did a copy-read of all &s to small copy types as soon as they were initialized
I've considered doing this in a MIR optimization. Not sure I have the time/patience to attempt it right now, but the transformation shouldn't be wildly complicated: Find functions that have &T arguments where T: Freeze, insert new locals which are just derefs of the references as the first statements in the function, replace derefs of the parameters with the new locals.
As far as I know this is exactly the transformation that dereferenceable authorizes. I'm sure such a transformation couldn't be enabled by default, but it would be interesting to try out in a branch.
I've been playing around with this and it requires some enhanced persuasion to get LLVM to agree that all the values in a chunk-reference are read. After that things vectorize properly.
Activity
leonardo-m commentedon Dec 4, 2022
Unrelated: adding some parentesys could improve the clarity of this code:
jruderman commentedon Dec 4, 2022
@rustbot label +I-slow
workingjubilee commentedon Dec 5, 2022
Strictly answering this question: You are working with
&Tand then calling.iter(), which creates nested indirections. Yes, the cost of an indirection, especailly nested indirections, is indeed probably something you should be aware of when you are working with plain, "by-value" data, and thus you should probably try to avoid it.Also your signature should probably be
But that doesn't make a difference to codegen.
This issue is one of the ones that motivated:
jruderman commentedon Dec 5, 2022
Why is
variant_avalid, and why does it create a nested indirection? I sorta understand the non-tuple case described in an old reddit thread, but I don't understand how you can destructure a tuple and still have the wrong level of indirection.leonardo-m commentedon Dec 5, 2022
Both versions are idiomatic. And I think Clippy doesn't fire on them.
hashworks commentedon Dec 5, 2022
I'm using clippy, and yes, it doesn't.
workingjubilee commentedon Dec 5, 2022
Yes, this code is not unusual and ideally we'd compile it optimally in all cases, but creating indirections is Known to sometimes affect performance. It can sometimes be preferable to e.g. call
.into_iter()instead of.iter()for this reason (or do the opposite, for other reasons).If you destructure some
&tupleinto(A, B), you will get(&A, &B). This happens due to... you know, I forget exactly which, at this point.jruderman commentedon Dec 5, 2022
I see. So
variant_abindsaetc as references, and the comparisons either doing impl-for-ref or auto-derefs like:It only seems to keep one level of references, though.
workingjubilee commentedon Dec 5, 2022
Indeed. The nesting is only somewhat relevant because it requires more work to remove both levels. And I believe it is hitting this implementation:
Arnavion commentedon Dec 5, 2022
Match ergonomics. Without it the equivalent code would've been
&&(ref a, ref b, ref c, ref d). Of course if one was writing that they would notice thatusizeis Copy so they can skip therefs, which would happen to solve the slowness issue. (This same logic is how I came up with the fix for OP.) But since we do have match ergonomics, code like OP's is very common, so it would be nice if it didn't have a penalty.3 remaining items
scottmcm commentedon Feb 23, 2023
This is at least highly related to #106269 (comment) and #103024
The copying isn't actually necessary to fix it. All LLVM needs to know is that it's a good idea to pre-read them, so this (variant C) also fixes it: https://rust.godbolt.org/z/W4hqEjj6c
By using non-short-circuiting operations LLVM doesn't need to worry about "is it ok to read that?" because it'll have to read everything. And then the right thing happens.
To further emphasize this, note that this (variant D) does not fix the problem, despite copying-via-dereference all the values, as the short-circuiting logic still means it's not "obviously" reading everything: https://rust.godbolt.org/z/4zPfMejcP
I wonder if it would fix it if optimized builds always did a copy-read of all
&s to small copy types as soon as they were initialized, and let LLVM just optimize them away if they aren't actually CSE'd into something useful...EDIT: Oh, I came up with an even smaller demonstration. This also fixes it: https://rust.godbolt.org/z/s3EKzWesM
Because that one
&is enough to say that it'll read all 4 values, and thus it knows the rest of the expression is side-effect-free and doesn't need to preserve the short-circuiting.One could also consider looking at it like this, if I got my truth tables right:
Which is nice and short and looks (based on the ASM) like it'd also be fast. (It's clearly non-short-circuiting.)
saethlin commentedon Feb 24, 2023
I've considered doing this in a MIR optimization. Not sure I have the time/patience to attempt it right now, but the transformation shouldn't be wildly complicated: Find functions that have
&Targuments whereT: Freeze, insert new locals which are just derefs of the references as the first statements in the function, replace derefs of the parameters with the new locals.As far as I know this is exactly the transformation that
dereferenceableauthorizes. I'm sure such a transformation couldn't be enabled by default, but it would be interesting to try out in a branch.the8472 commentedon Jul 20, 2024
I've been playing around with this and it requires some enhanced persuasion to get LLVM to agree that all the values in a chunk-reference are read. After that things vectorize properly.
https://rust.godbolt.org/z/YxPEbjf1j