ByteQueue: Avoid unnecessary initialization, and take Read objs#1280
ByteQueue: Avoid unnecessary initialization, and take Read objs#1280sporksmith merged 5 commits intoshadow:devfrom
Conversation
Codecov Report
@@ Coverage Diff @@
## dev #1280 +/- ##
==========================================
+ Coverage 55.65% 55.68% +0.02%
==========================================
Files 141 141
Lines 20619 20617 -2
Branches 5068 5068
==========================================
+ Hits 11476 11480 +4
+ Misses 6020 6015 -5
+ Partials 3123 3122 -1
Flags with carried forward coverage won't be shown. Click here to find out more.
Continue to review full report at Codecov.
|
770636a to
aed8b71
Compare
|
@stevenengler After sleeping on it realized a couple things I want to check/change. Removing you as a reviewer for the moment but feel free to continue if you've already started. I think it'll be small, and I won't rebase... |
Feel free to rebase if it's easier. I've skimmed through it but haven't written comments yet. |
|
Done. Narrowed the scope of the Reader and Writer implementations to what we're actually using now. I was also double-checking whether it was really ok to remove the I think we needed this before because when getReadablePtr and getWritablePtr needed to copy, we could end up copying much more than we actually use. Now that the memory reads/writes are deferred until the descriptor actually knows how much data to access I don't think we need it. |
stevenengler
left a comment
There was a problem hiding this comment.
Cool, this reader/writer design looks nice!
| // the write would block if we could not write any bytes, but were asked to | ||
| if num_written == 0 && !bytes.is_empty() { | ||
| if usize::from(num_written) == 0 && bytes.bytes().next().is_some() { | ||
| Err(nix::errno::EWOULDBLOCK.into()) |
There was a problem hiding this comment.
Won't bytes.bytes().next() consume from the reader, so a byte from the reader will be lost? I think a more general issue is that the read(fd, count) syscall reads a specific number of bytes, but the length of astd::io::Read is unknown (and potentially unbounded). Maybe the read() and write() functions should take another argument for the max number of bytes (or maybe a std::io::Take) instead?
There was a problem hiding this comment.
I think a more general issue is that the read(fd, count) syscall reads a specific number of bytes, but the length of astd::io::Read is unknown (and potentially unbounded).
Yup!
Maybe the read() and write() functions should take another argument for the max number of bytes (or maybe a std::io::Take) instead?
A Take object also implements Read. If the caller wants to put a cap on how much data is read into the ByteQueue, they can already use Take to do so. We do this a few lines up from here.
Unfortunately I don't think there is such an adapter in the standard library for Write. I don't think we'll need it in practice though, and if we do, I think it'd be cleaner to find or create such an adapter (Give?) .
Won't bytes.bytes().next() consume from the reader, so a byte from the reader will be lost?
Yeah; I was thinking this was ok since at this point nothing else is going to read from this reader object, but thinking about it again I agree this could end up being surprising behavior later.
In an earlier iteration ProcessMemoryReader and ProcessMemoryWriter also implemented the Seek trait, and I required the Seek trait for R here. Then here we can check whether seek(SeekFrom::Start(1)).is_ok(), and then rewind it before returning via seek(SeekFrom::Start(0)). I think I'll probably put that back in, but am open to other ideas.
There was a problem hiding this comment.
A Take object also implements Read. If the caller wants to put a cap on how much data is read into the ByteQueue, they can already use Take to do so. We do this a few lines up from here.
The caller can use take() to limit how much data, but if the PipeFile::write() accepts only a Read, then it loses that information (it can't know the limit without reaching that limit).
Unfortunately I don't think there is such an adapter in the standard library for Write. I don't think we'll need it in practice though, and if we do, I think it'd be cleaner to find or create such an adapter (Give?) .
Looking at this again, I think I left a bug in PipeFile::read(), where it's not returning a EWOULDBLOCK instead of 0 if the pipe is empty :( So I think we will also need to know if the Write is non-empty or not.
In an earlier iteration ProcessMemoryReader and ProcessMemoryWriter also implemented the Seek trait, and I required the Seek trait for R here. Then here we can check whether seek(SeekFrom::Start(1)).is_ok(), and then rewind it before returning via seek(SeekFrom::Start(0)). I think I'll probably put that back in, but am open to other ideas.
I've been trying to think of an alternative to Seek, but I can't think of any.
There was a problem hiding this comment.
Done. This cascaded a bit - it seemed silly to implement Seek twice, so I extracted that and some other pointer arithmetic into TypedPluginPtr.
I ended up backporting std::io::Seek::stream_len. Not sure this is the best way to do it. Do you know if we switched to nightly, would we only get nightly-only features that we explicitly opt into? If that's the case, maybe it'd be worth thinking about.
src/main/host/descriptor/pipe.rs
Outdated
| if written > 0 { | ||
| self.adjust_status(FileStatus::READABLE, !self.is_empty(), event_queue); | ||
| self.adjust_status( | ||
| FileStatus::WRITABLE, | ||
| self.space_available() > 0, | ||
| event_queue, | ||
| ); | ||
| } |
There was a problem hiding this comment.
I think it's better to leave out the if written > 0 condition. If something called write() but no bytes were written, then we should probably check if we should remove the FileStatus::WRITABLE flag to prevent a possible infinite loop. (A similar bug appeared in the C code a while back and I remember Rob spent a lot of time debugging it, so I think it's useful to be safe.)
There was a problem hiding this comment.
Oops! Yeah, I think I'd misunderstood what was going on here.
| ProcessMemoryReader::<T> { | ||
| process: self, | ||
| ptr: src, | ||
| len, | ||
| _phantom: std::marker::PhantomData, | ||
| } |
There was a problem hiding this comment.
Maybe rename len to count? When I think of len in the context of a PluginPtr, I think of the number of bytes since a PluginPtr doesn't have a distinct type.
There was a problem hiding this comment.
Good point! Done
src/main/host/process.rs
Outdated
| impl<'a> std::io::Read for ProcessMemoryReader<'a, u8> { | ||
| fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { |
There was a problem hiding this comment.
How expensive are functions like process_readPtr()? One concern is that if the code calls something like read_to_end() for this reader, it will call read() iteratively, making many process_readPtr() calls. But if the overhead of each process_readPtr() call is small, then this shouldn't be an issue.
There was a problem hiding this comment.
The individual calls are relatively expensive if we don't go through the MemoryManager path for whatever reason, especially now that we've disabled buffering by default. This is generally the expectation with Read objects though, so generally things that take a Read object should try to minimize individual read calls.
e.g. from https://doc.rust-lang.org/std/io/trait.Read.html:
Please note that each call to read() may involve a system call, and therefore, using something that implements BufRead, such as BufReader, will be more efficient.
(Though I'd argue they should replace "will" with "may be", since addering a buffering layer is less efficient if you are accessing in large chunks)
If/when a consumer needs to do small sequential accesses, then can wrap a Read object in a BufReader
src/main/host/process.rs
Outdated
| /// Unlike `ProcessReader::as_ref`, the result's lifetime is bound to that of | ||
| /// *this* object, so that we can ensure writes are flushed. |
There was a problem hiding this comment.
I think this name needs to be updated (there is another reference to this ProcessReader as well).
src/main/utility/byte_queue.rs
Outdated
| pub fn push(&mut self, src: &[u8]) { | ||
| // A slice reader should be infallible. | ||
| self.push_from_reader(src).unwrap(); | ||
| } |
There was a problem hiding this comment.
Since a &[u8] implements Read and Write, should push_from_reader() just be renamed to push() since it can also accept slices? (And the same for pop().)
There was a problem hiding this comment.
Done, though I could go either way on this one. I think the primary disadvantage of removing the slice-based APIs, is that a caller with a slice has to reason out that push/pop are actually infallible in that case to safely call unwrap. I added comments as a hint, though, and in practice I don't think we'll ever be calling it with a slice.
stevenengler
left a comment
There was a problem hiding this comment.
I still think we should avoid getting the extra byte with bytes.bytes().next() since it would be unexpected, but everything else looks good to me.
stevenengler
left a comment
There was a problem hiding this comment.
Looks good! It's nice to see how the API looks. Maybe in the future we could implement Read and Write on the PosixFiles themselves, then we could have a cool sendfile() implementation using std::io::copy() to move data from one descriptor type to another.
src/main/host/descriptor/pipe.rs
Outdated
| // the write would block if we could not write any bytes, but were asked to | ||
| if usize::from(num_read) == 0 && bytes.stream_len_bp()? != 0 { |
0e84a2d to
28b38af
Compare
u8 slices implement `Read` and `Write`, so those still work. When the caller has a `Read` or `Write` object that *isn't* a buffer though, this allows the ByteQueue to read/write it directly without an intermediate buffer. Also avoid unnecessary initialization of internal buffers, using `Read::read_to_end` to read directly into uninitialized memory.
* Adds ProcessMemoryReader and ProcessMemoryWriter. These implement the `Read` and `Write` traits respectively, as well as `Seek`. * Removes the previous memory access APIs from Process. * Updates Rust syscall handlers to use the new APIs, removing an extra copy to an intermediate buffer in pipe reads and writes.
28b38af to
f0408e6
Compare
Maybe! Do we ever need to copy directly from one descriptor to another? Note that std::io::copy adds an extra copy through an intermediate buffer; I don't think there's a way to avoid that given only a generic |
ProcessMemoryReaderandProcessMemoryWriter, which implementstd::io::Readandstd::io::Write.ProcessMemoryWriterto ensure writes are flushed. The Drop trait on this object flushes writes, and the object must be dropped to release a mutable reference to its Process.ByteQueuethat takeReadandWriteobjects.ByteQueueuse reserved but unfilled buffer capacity to avoid unnecessary initialization.ReadandWriteobjects instead of buffers.