Repository navigation
Add os.readinto API for reading data into a caller provided buffer #129205
Description
Feature or enhancement
Proposal:
Code reading data in pure python tends to make a buffer variable, call os.read() which returns a separate newly allocated buffer of data, then copy/append that data onto the pre-allocated buffer[0]. That creates unnecessary extra buffer objects, as well as unnecessary copies. Provide os.readinto for directly filling a Buffer Protocol object.
os.readinto should closely mirror _Py_read which underlies os.read in order to get the same behaviors around retries as well as well-tested cross-platform support.
Move simple cases that use os.read (ex. [0]) to use the new API when it makes code simpler and more efficient. Potentially adding readinto to more readable/writeable file-like proxy objects or objects which transform the data (ex. Lib/_compression) is out of scope for this issue.
[0]
Lines 1914 to 1921 in 298dda5
| # Wait for exec to fail or succeed; possibly raising an | |
| # exception (limited in size) | |
| errpipe_data = bytearray() | |
| while True: | |
| part = os.read(errpipe_read, 50000) | |
| errpipe_data += part | |
| if not part or len(errpipe_data) > 50000: | |
| break |
cpython/Lib/multiprocessing/forkserver.py
Lines 384 to 392 in 298dda5
| def read_signed(fd): | |
| data = b'' | |
| length = SIGNED_STRUCT.size | |
| while len(data) < length: | |
| s = os.read(fd, length - len(data)) | |
| if not s: | |
| raise EOFError('unexpected EOF') | |
| data += s | |
| return SIGNED_STRUCT.unpack(data)[0] |
Lines 1695 to 1701 in 298dda5
| def readinto(self, b): | |
| """Same as RawIOBase.readinto().""" | |
| m = memoryview(b).cast('B') | |
| data = self.read(len(m)) | |
| n = len(data) | |
| m[:n] = data | |
| return n |
os.read loops to migrate
Well contained os.read loops
-
multiprocessing.forkserver read_signed- @cmaloney - gh-129205: Update multiprocessing.forkserver to use os.readinto #129425 [x]subprocess Popen._execute_child- @cmaloney - gh-129205: Use os.readinto() in subprocess errpipe_read #129498
os.read loop interleaved with other code
-
_pyio FileIO.read FileIO.readall FileIO.readintosee, Reduce copies when reading files in pyio, match behavior of _io #129005 -- @cmaloney -
_pyrepl.unix_console UnixConsole.input_buffer-- fixed length underlying buffer with "pos" / window on top. -
pty _copy. Operates around a "high waterlevel" / attempt to have a fixed-ish size buffer. Wrapsos.readwith a_readfunction. -
subprocess Popen.communicate. Note, this feels like something non-contiguous Py_buffer would be really good for, particularly inself.text_modewhere currently all the bytes are "copied" into a contiguousbytesto turn then turn into text... -
tarfile _Stream._read and _Stream.__read. Note, builds _LowLevelFile aroundos.read, but other read methods also available.
Has this already been discussed elsewhere?
No response given
Links to previous discussion of this feature:
Linked PRs
- gh-129205: Add os.readinto API for reading data into a caller provided buffer #129211
- gh-129205: Modernize test_eintr #129316
- gh-129205: Update multiprocessing.forkserver to use os.readinto #129425
- gh-129205: Use os.readinto() in subprocess errpipe_read #129498
- gh-129205: Experiment BytesIO._readfrom() #130098
Activity
vstinner commentedon Jan 23, 2025
Do you want to work on a PR? If not, I can if you prefer.
cmaloney commentedon Jan 23, 2025
Almost done with one for adding
os.readinto+ tests :). Just trying to make sure I port / replicate the rightos.readtests (but not all / _Py_read is used by both and handles most cases). Definitely be nice to both work + reviewos.read->os.readintoas appropriate if you're up for that (there's quite a bit in multiprocessing, subprocess, ...).pythongh-129205: Add os.readinto API
bluetech commentedon Jan 23, 2025
Just curious, how would you rewrite your first example using
os.readinto?cmaloney commentedon Jan 23, 2025
@bluetech Ideally to me they'd move to
file.read()/ theiofile object stack which would give the desired "read until EOF or max_bytes is reached efficiently" behavior. Already that is efficient in a lot of cases, but can't be used everywhere currently. Mixture of some overhead that would need to be reduced to match performance (locks, buffer allocation, etc) + relative age of different parts of the codebase (iocame in Python 3.0, many other python modules are older). That case I would like to be aBufferedIOwith a 0-byte buffer doing a.read()with a "max_size" parameter, which needs a little bit of new features on BufferedIO to handle the use case. Currently though it's open coded.Migrating cases like that should happen, and I suspect whats the simplest/cleanest will evolve with code review. My prototype has looked something like:
Are some behavior differences between that and the code as implemented today (Today after reading 49_999 bytes, could get 50_000 bytes resulting in 99_999 bytes in
errpipe_data, but my prototype never goes past 50_000 bytes).Not sure if it's good / needed to include the
-1case (and probably something I should add a test around.. Returning 0, number of bytes read, or throwing an exception would definitely simplify call site hadnling a bit...I also like doing the same thing but with a
matchinstead of the if.bluetech commentedon Jan 23, 2025
Side note, it does look like the original code is meant to be
len(errpipe_data) >= 50000instead of>, but it doesn't matter much I suppose.gh-129205: Add os.readinto() API for reading data into a caller provi…
pythongh-129205: Modernize test_eintr
pythongh-129205: Modernize test_eintr
cmaloney commentedon Jan 26, 2025
os.readloops to migrateI added a list to the base issue of
os.readloops I think could be migrated. Comment if there's one you want to claim, and/or justcc: @cmaloneyme in the PR (I don't have access to the sub task lists preview which the audit thread safety issue uses unfortunately)The interleaved with other code/classes may be not be worth migrating / difficult to refactor without breaking existing code.
There are a number of places which do "IO like" objects with
.readmethods, excluding those from this issue. Some cases are likely worth investigating independently, but there is a lot more variation and surrounding context needed to make changes.I think there's also some significant code golf around "shortest way to use
os.readinto", my current variant has an issue of one morereadintocall than needed in the "got all bytes the first call case" (which is the common fast path...).gh-129205: Modernize test_eintr (#129316)
pythongh-129205: Update multiprocessing.forkserver to use os.readinto
gh-129205: Update multiprocessing.forkserver to use os.readinto() (#1…
pythongh-129205: Use os.readinto() in subprocess errpipe_read
gpshead commentedon Feb 2, 2025
The subprocess
_execute_childone doesn't need changing, see rationale in the closed PR.readinto an existing bytearray has a caveat: instead of a malloc, it does a malloc + bzero as pre-sized bytearrays don't have an undefined value concept. so it goes beyond just mapping pages for a buffer to writing all pages, no matter how much data is actually being read. when you're rarely/never going to be reading a lot of data or reusing the buffer, that is unneeded extra work.
cmaloney commentedon Feb 2, 2025
@gpshead I commented on the closed PR, neither
bytearraynorbytesin the code I've read does a zeroing of its initial allocation. Just does amalloc, with bytearray adding the one caveat of writing a single null-byte at the end of the array.Will definitely take as a general note not to migrate loops unless there's a measurable / significant performance change.
pythongh-129205: Update multiprocessing.forkserver to use os.readinto…
GalaxySnail commentedon Feb 7, 2025
What about adding an
os.preadinto()forpread? Currentlyos.pread()doesn't support pre-allocated buffers either.cmaloney commentedon Feb 7, 2025
@GalaxySnail could you share the library / code you have that want to be more efficient? Thinking through multiple options and want to make sure would work here. Can always add more apis, but each one is also more maintenance burden (more code to compile, tests to run each build, etc). If it's a big improvement in shipping code, can be worth it. A separate bigger scale project I'm looking at is more general usage of background/async I/O APIS (io_uring and friends), and one of the paths would be making those available, and rather than encouraging one refactor now (
os.pread->os.preadinto) and potentially one later (os.preadinto-> more async), just making sure thatos.pread-> more async will be a straightforward and good win.GalaxySnail commentedon Feb 8, 2025
Sadly I don't have any real-world code that want to be more efficient. I'm not suggesting optimizing the stdlib with
pread, it just feels a bit strange that we haveos.readinto()but notos.preadinto(). If we had it, third-party code could easily use it. Compared to the implementation and tests foros.readv()andos.preadv(), it shouldn't be too difficult to implement and testos.preadinto(), especially sincepreadis not available on Windows.That sounds exciting!
cmaloney commentedon Feb 13, 2025
Rather than directly moving loops, I have been experimenting with a
BytesIO._readfrom(file, /, *, estimate=None, limit=None)that encapsulates the buffer resizing efficiently as well as the read loop, adding common code for features "estimated size" and "limit size". It can be used to implementFileIO.readallby calling_readfromwith estimate with minimal perf change (is in PR). In general I think "If there is a read loop, it should be faster and simpler".Draft PR: #130098
The
_pyioimplementation supports for three "shapes" of IO objects: file descriptorint, those with a.readintomember, and those with.readmember. If that looks like a reasonable approach, I'd likely introduce it as a internal methodBytesIO._readfrom()and move cases (with perf tests to make sure things don't regress).In the C implementation I included an optimization to avoid a heap allocation by using 1KB of stack space in the
estimate=0case instead. Not sure that's worth the complexity and cost (if it gets used, need an extra copy compared to just using a bytes; and a warmed up interpreter feels like 1KB PyBytes is likely to be quickly available / allocated).The CPython codebase has a common pattern of build a list of I/O chunks than "join" them together at the end of the loop. I think readfrom makes a tradeoff in that case, in that as long as resize infrequently copies (not lots of other heap objects being allocated), it should be faster than that single large copy at the end. I haven't run full performance numbers though.
For broader ecosystem, if a public API potentially helps solve the cases in python libraries where there is an
fdbut user's today avoidopen()/ the standard Python I/O stack and hand-write a read loop to fill a buffer. Already have a couple new public APIs though, and it feels likely in moving + perf testing individual migrations inside CPython might tweak API a bit / so my leaning is to keep internal implementation detail for now.Thoughts?
cc: @gpshead, @vstinner
vstinner commentedon Feb 20, 2025
os.readinto() was added, I suggest to close this issue.
Further work / optimization work should be done in new separated issues.
cmaloney commentedon Feb 20, 2025
I'm good witth closing this, doing other optimizations as small separate projects where they make sense.
vstinner commentedon Feb 20, 2025
Adding os.readinto() is a nice enhancement, thanks @cmaloney!