Fix perf regression in Read::read_to_end on short reads due to not checking if the cursor has initialized bytes - #158083
Conversation
|
r? @Darksonn rustbot has assigned @Darksonn. Use Why was this reviewer chosen?The reviewer was selected based on:
|
Read::read_to_end on short reads due to not checking if the cursor has initialized bytes
| // Note that we don't track already initialized bytes here, but this is fine | ||
| // because we explicitly limit the read size |
There was a problem hiding this comment.
This comment was added in #150129. Should it be removed?
There was a problem hiding this comment.
Unsure. Are these comments still relevant @a1phyr?
There was a problem hiding this comment.
Well, probably not if you start tracking initialized bytes :)
|
@rustbot author |
|
Reminder, once the PR becomes ready for a review, use |
104baaa to
aebb8e1
Compare
aebb8e1 to
c6406c5
Compare
There was a problem hiding this comment.
I didn't track uninitialized bytes in my previous MR because I thought it would be to complicated to do properly.
For example, if this MR improve some existing cases, it will not really solve the pathological case you sent in your issue for larger sizes (eg around a million): when you initialized N bytes, on the next round you will have N-1 spare initialized bytes left, so you won't be able to use set_init() (or you could initialize the rest manually).
All in all, it was a trade-off between code complexity, properly handling common cases but having suboptimal (but still acceptable) behavior in weird cases.
| } | ||
| }; | ||
|
|
||
| initialized_len = cursor.capacity(); |
There was a problem hiding this comment.
This is true only if read_buf.is_init() (use the boolean below to avoid lifetime issues)
| let mut read_buf: BorrowedBuf<'_, u8> = spare.into(); | ||
|
|
||
| let buf_unfilled_len = read_buf.capacity() - read_buf.len(); | ||
| if initialized_len == buf_unfilled_len { |
There was a problem hiding this comment.
This condition is wrong: you compare the old buffer capacity and the new buffer spare capacity, but the start of the buffer has changed since then. It would be less error prone to track initialized bytes counting from the beginning of the Vec.
There was a problem hiding this comment.
Yeah, please store the full capacity of the vector, including anything already written.
|
@rustbot author |
c6406c5 to
c0189d9
Compare
|
@rustbot ready |
|
This is still using the wrong condition. Consider this scenario:
But in this scenario the buffer was resized and not initialized, so this is wrong. It's really important that Instead of storing @rustbot author |
Just to clarify, resizing only occurs potentially in spots where we call |
|
It's not obvious to me that that's the case. Maybe you are right, but I can't easily follow the logic. To me, it would be a lot easier to figure out that the code is correct if you stored the capacity. If the vector was reallocated, then I know that the capacity changed, and therefore I know that we will not call |
I think it does because it seems like the rust/library/std/src/io/mod.rs Lines 574 to 581 in 36714a9 If I'm understanding it correctly, the The reason why I asked if it was safe to do |
|
I'm sorry it looks like you're right. I misunderstood the However, I don't think the current logic looks ideal. Let's say that we have a buffer of capacity 1000 and
So it seems like that on short reads, we should repeatedly call |
c0189d9 to
c4bc8ab
Compare
This comment has been minimized.
This comment has been minimized.
c4bc8ab to
d0ef9b7
Compare
This comment has been minimized.
This comment has been minimized.
d0ef9b7 to
603ccae
Compare
|
@rustbot ready |
| } else { | ||
| written_bytes = 0; | ||
| } | ||
| } | ||
|
|
||
| if buf.len() == buf.capacity() { | ||
| // buf is full, need more space | ||
| buf.try_reserve(PROBE_SIZE)?; | ||
| written_bytes = 0; |
There was a problem hiding this comment.
I think it would be less confusing to also set is_init to false here.
| // Additively counts how many bytes we wrote into buf in each | ||
| // iteration of the loop; it's used to see if we should initialize | ||
| // more bytes or re-use the initialized buffer space. | ||
| let mut written_bytes = 0; |
There was a problem hiding this comment.
This variable keeps track of what operations we have done in the past, but I think it's generally easier to think about variables that keep track of facts about the world.
So for example, could we store the length of the subset of the buffer that we are currently reading into? Before each iteration you do if buf_len > spare.len() { buf_len = spare.len(); }. And after each iteration you do buf_len -= bytes_read followed by if buf_len == 0 { buf_len = max_read_size; }. The is_init variable then keeps track of whether the current subset is initialized or not.
There was a problem hiding this comment.
I ended up refactoring and using the is_init variable and spare buffer length to determine whether we need to initialize more bytes into the spare buffer or not.
If our spare buffer has a remainder from the modulo operation with max_read_size, it should mean that there are bytes there that we have initialized; otherwise, if we see a 0, then that indicates that we need to initialize more bytes into the spare buffer (aside from the case when our spare buffer is the minimum between itself and the max_read_size in which case taking the mod of that with itself would always produce 0 and anyways we just need to present that remaining spare buffer length).
This comment has been minimized.
This comment has been minimized.
603ccae to
b28eb14
Compare
|
This PR was rebased onto a different main commit. Here's a range-diff highlighting what actually changed. Rebasing is a normal part of keeping PRs up to date, so no action is needed—this note is just to help reviewers. |
There was a problem hiding this comment.
I believe you have to set is_init to false here too? Since this may increase the number of bytes passed in the next iteration.
There was a problem hiding this comment.
Doesn't is_init get set to false in the next loop? I believe this conditional doubles max_read_size when it notices that we filled the spare buffer with max_read_size bytes in one iteration rather than through multiple iterations. From the next loop, I think the buf.len() == buf.capacity() conditional earlier should be triggered, resize the buffer, and set is_init to false.
Also, now that I realize this, I think it would be impossible for buf_len > max_read_size because we already accounted for re-using any remaining uninitialized space in the spare buffer rather than creating more uninitialized bytes. I could change this conditional to just bytes_read == max_read_size?
66dba61 to
660f9c5
Compare
There was a problem hiding this comment.
This conditional right here (which is effectively buf.len() == start_cap due to transitivity) only seems to be ran once to check if the buffer's length is the same as the starting buffer capacity (the ideal case here is that small_probe_read returns 0 and hence we can say for sure that whatever we read into the buffer was an exact fit + avoided doubling the buffer's capacity). Otherwise, if there were more bytes to read, then our buffer's capacity grows and it should be impossible for buf.len() == start_cap (start_cap does not change); therefore, I took this out of the loop since we don't need to run through this multiple times.
…ecking if the cursor has initialized bytes and refactored main loop code
660f9c5 to
4cfc77b
Compare
| } | ||
|
|
||
| let (was_init, buf_len) = if init_until > buf.len() { |
There was a problem hiding this comment.
Still need was_init to set BorrowedBuf init field to true, and I use it down below so that we're not repeatedly assigning init_until with the same value unless we reach a point where we need to initialize more bytes into the spare buffer.
| let is_init = read_buf.is_init(); | ||
|
|
||
| if !was_init { | ||
| init_until = buf.len() + buf_len; | ||
| } |
There was a problem hiding this comment.
I think this needs to be if is_init { ... } instead. Right now you might update init_until even if is_init == false.
There was a problem hiding this comment.
Right now you might update init_until even if
is_init == false.
Would it be okay to do !was_init && is_init here? This would allow us to avoid repeatedly assigning init_until when we know that it was initialized from a previous loop
There was a problem hiding this comment.
I guess it's okay, but I don't really see the advantage.
There was a problem hiding this comment.
It probably isn't visible performance-wise. When I was thinking about what init_until was reassigning itself to mentally, I noticed that init_until was updating itself to the same value on the number of initialized bytes in the buffer during short reads.
The was_init variable from earlier keeps track if we had an initialized buffer from previous loop. I just thought it would be cheaper to check through booleans before triggering a reassignment on init_until with usize being 4/8 bytes.
There was a problem hiding this comment.
Well, performance-wise here is my take:
I would expect if is_init {} to be easier for the branch predictor to predict since most IO resources generally always return false or always return true. Cases where the IO resource dynamically does on or the other are pretty rare. Branches are much more expensive than a single addition.
Also, using was_init here means its value probably has to be stored on the stack while read_buf is invoked, whereas if you don't use it here, it can be discarded before invoking read_buf, meaning it may be able to just store the boolean in a register.
Anyways. Ultimately all of these are just guesses without measuring performance or looking at assembly, and in practice I don't think it matters.
| // The buffer might be an exact fit. Let's read into a probe buffer | ||
| // and see if it returns `Ok(0)`. If so, we've avoided an | ||
| // unnecessary doubling of the capacity. But if not, append the | ||
| // probe buffer to the primary buffer and let its capacity grow. | ||
| if buf.len() == start_cap { | ||
| let read = small_probe_read(r, buf)?; |
There was a problem hiding this comment.
Why was this moved outside of the loop? I think it needs to be inside the loop because buf.len() will usually be zero here, so it will basically never trigger.
There was a problem hiding this comment.
I thought the code above this will call on small_probe_read, so it would be possible for buf.len() to be equal to start_cap?
I didn't see a reason for this needed to remain in the loop since this was a conditional that triggers once on buf.len() == start_cap. The moment we expand the buffer above that start_cap, there shouldn't be a way for that conditional to be triggered. It doesn't seem like we update start_cap either.
There was a problem hiding this comment.
My understanding is that the purpose of this code is to handle the case where you are reading a, for example 10 MB file, and so you allocate exactly 10 MB, and read those into the buffer over a couple of iterations. Eventually when you have read the file, you want to avoid resizing the buffer when it's not needed, and so the small probe read allows you to check whether the buffer had exactly the right size without resizing it.
There was a problem hiding this comment.
That makes sense to me. I forgot to account for that we could be reading those bytes in and reaching the start_cap over multiple iterations.
I guess in that case would it be beneficial to do a small_probe_read every time we reach buf.len() == buf.capacity()? It can serve as both a way to prevent re-allocating the buffer if we know our read returns 0, but also it should re-allocate the buffer for us in case there are more bytes to read.
Actually, on second thought, that doesn't sound like a good a idea since it expects people to determine how the buffer resize and the capacity it grows to and make sure to read in that many bytes (we maybe have some unnecessary reads). I'll revert to the original
There was a problem hiding this comment.
Performing a small probe read only makes sense if you think there is a good chance you have hit the size exactly correctly, down to the byte. Outside of the start_cap case, that's pretty unlikely.
View all comments
This PR fixes #158008.
In particular, in #150129, it refactored some code within
library/std/io/mod.rsto utilizeBorrowedBuf::is_initinstead of manually checkingread_buf.init_len() == buf_lento see if the read buffer had initialized bytes. However, theBorrowedBufis never marked or set as init within this function, and I think this portion of the code:was removed by mistake. This PR reverts the changes made by #150129, so that we can mark the
BorrowedBuf/read_bufas initialized usingBorrowedBuf::set_initif in a previous iteration the cursor has initialized bytes. This would allowmax_read_sizeto not be marked asusize::maxif the read buffer contains initialized bytes.