Skip to content

Commit 4a172cc

Browse files
authored
bpo-33671: efficient zero-copy for shutil.copy* functions (Linux, OSX and Win) (#7160)
* have shutil.copyfileobj use sendfile() if possible * refactoring: use ctx manager * add test with non-regular file obj * emulate case where file size can't be determined * reference _copyfileobj_sendfile directly * add test for offset() at certain position * add test for empty file * add test for non regular file dst * small refactoring * leave copyfileobj() alone in order to not introduce any incompatibility * minor refactoring * remove old test * update docstring * update docstring; rename exception class * detect platforms which only support file to socket zero copy * don't run test on platforms where file-to-file zero copy is not supported * use tempfiles * reset verbosity * add test for smaller chunks * add big file size test * add comment * update doc * update whatsnew doc * update doc * catch Exception * remove unused import * add test case for error on second sendfile() call * turn docstring into comment * add one more test * update comment * add Misc/NEWS entry * get rid of COPY_BUFSIZE; it belongs to another PR * update doc * expose posix._fcopyfile() for OSX * merge from linux branch * merge from linux branch * expose fcopyfile * arg clinic for the win implementation * convert path type to path_t * expose CopyFileW * fix windows tests * release GIL * minor refactoring * update doc * update comment * update docstrings * rename functions * rename test classes * update doc * update doc * update docstrings and comments * avoid do import nt|posix modules if unnecessary * set nt|posix modules to None if not available * micro speedup * update description * add doc note * use better wording in doc * rename function using 'fastcopy' prefix instead of 'zerocopy' * use :ref: in rst doc * change wording in doc * add test to make sure sendfile() doesn't get called aymore in case it doesn't support file to file copies * move CopyFileW in _winapi and actually expose CopyFileExW instead * fix line endings * add tests for mode bits * add docstring * remove test file mode class; let's keep it for later when Istart addressing OSX fcopyfile() specific copies * update doc to reflect new changes * update doc * adjust tests on win * fix argument clinic error * update doc * OSX: expose copyfile(3) instead of fcopyfile(3); also expose flags arg to python * osx / copyfile: use path_t instead of char * do not set dst name in the OSError exception in order to remain consistent with platforms which cannot do that (e.g. linux) * add same file test * add test for same file * have osx copyfile() pre-emptively check if src and dst are the same, otherwise it will return immedialtey and src file content gets deleted * turn PermissionError into appropriate SameFileError * expose ERROR_SHARING_VIOLATION in order to raise more appropriate SameFileError * honour follow_symlinks arg when using CopyFileEx * update Misc/NEWS * expose CreateDirectoryEx mock * change C type * CreateDirectoryExW actual implementation * provide specific makedirs() implementation for win * fix typo * skeleton for SetNamedSecurityInfo * get security info for src path * finally set security attrs * add unit tests * mimick os.makedirs() behavior and raise if dst dir exists * set 2 paths for OSError object * set 2 paths for OSError object * expand windows test * in case of exception on os.sendfile() set filename and filename2 exception attributes * set 2 filenames (src, dst) for OSError in case copyfile() fails on OSX * update doc * do not use CreateDirectoryEx() in copytree() if source dir is a symlink (breaks test_copytree_symlink_dir); instead just create a plain dir and remain consistent with POSIX implementation * use bytearray() and readinto() * use memoryview() with bytearray() * refactoring + introduce a new _fastcopy_binfileobj() fun * remove CopyFileEx and other C wrappers * remove code related to CopyFileEx * Recognize binary files in copyfileobj() ...and use fastest _fastcopy_binfileobj() when possible * set 1MB copy bufsize on win; also add a global _COPY_BUFSIZE variable * use ctx manager for memoryview() * update doc * remove outdated doc * remove last CopyFileEx remnants * OSX - use fcopyfile(3) instead of copyfile(3) ...as an extra safety measure: in case src/dst are "exotic" files (non regular or living on a network fs etc.) we better fail on open() instead of copyfile(3) as we're not quite sure what's gonna happen in that case. * update doc
1 parent 33cd058 commit 4a172cc

File tree

8 files changed

+595
-19
lines changed

8 files changed

+595
-19
lines changed

Doc/library/shutil.rst

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ Directory and files operations
5151
.. function:: copyfile(src, dst, *, follow_symlinks=True)
5252

5353
Copy the contents (no metadata) of the file named *src* to a file named
54-
*dst* and return *dst*. *src* and *dst* are path names given as strings.
54+
*dst* and return *dst* in the most efficient way possible.
55+
*src* and *dst* are path names given as strings.
56+
5557
*dst* must be the complete target file name; look at :func:`shutil.copy`
5658
for a copy that accepts a target directory path. If *src* and *dst*
5759
specify the same file, :exc:`SameFileError` is raised.
@@ -74,6 +76,10 @@ Directory and files operations
7476
Raise :exc:`SameFileError` instead of :exc:`Error`. Since the former is
7577
a subclass of the latter, this change is backward compatible.
7678

79+
.. versionchanged:: 3.8
80+
Platform-specific fast-copy syscalls may be used internally in order to
81+
copy the file more efficiently. See
82+
:ref:`shutil-platform-dependent-efficient-copy-operations` section.
7783

7884
.. exception:: SameFileError
7985

@@ -163,6 +169,11 @@ Directory and files operations
163169
Added *follow_symlinks* argument.
164170
Now returns path to the newly created file.
165171

172+
.. versionchanged:: 3.8
173+
Platform-specific fast-copy syscalls may be used internally in order to
174+
copy the file more efficiently. See
175+
:ref:`shutil-platform-dependent-efficient-copy-operations` section.
176+
166177
.. function:: copy2(src, dst, *, follow_symlinks=True)
167178

168179
Identical to :func:`~shutil.copy` except that :func:`copy2`
@@ -185,6 +196,11 @@ Directory and files operations
185196
file system attributes too (currently Linux only).
186197
Now returns path to the newly created file.
187198

199+
.. versionchanged:: 3.8
200+
Platform-specific fast-copy syscalls may be used internally in order to
201+
copy the file more efficiently. See
202+
:ref:`shutil-platform-dependent-efficient-copy-operations` section.
203+
188204
.. function:: ignore_patterns(\*patterns)
189205

190206
This factory function creates a function that can be used as a callable for
@@ -241,6 +257,10 @@ Directory and files operations
241257
Added the *ignore_dangling_symlinks* argument to silent dangling symlinks
242258
errors when *symlinks* is false.
243259

260+
.. versionchanged:: 3.8
261+
Platform-specific fast-copy syscalls may be used internally in order to
262+
copy the file more efficiently. See
263+
:ref:`shutil-platform-dependent-efficient-copy-operations` section.
244264

245265
.. function:: rmtree(path, ignore_errors=False, onerror=None)
246266

@@ -314,6 +334,11 @@ Directory and files operations
314334
.. versionchanged:: 3.5
315335
Added the *copy_function* keyword argument.
316336

337+
.. versionchanged:: 3.8
338+
Platform-specific fast-copy syscalls may be used internally in order to
339+
copy the file more efficiently. See
340+
:ref:`shutil-platform-dependent-efficient-copy-operations` section.
341+
317342
.. function:: disk_usage(path)
318343

319344
Return disk usage statistics about the given path as a :term:`named tuple`
@@ -370,6 +395,28 @@ Directory and files operations
370395
operation. For :func:`copytree`, the exception argument is a list of 3-tuples
371396
(*srcname*, *dstname*, *exception*).
372397

398+
.. _shutil-platform-dependent-efficient-copy-operations:
399+
400+
Platform-dependent efficient copy operations
401+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
402+
403+
Starting from Python 3.8 all functions involving a file copy (:func:`copyfile`,
404+
:func:`copy`, :func:`copy2`, :func:`copytree`, and :func:`move`) may use
405+
platform-specific "fast-copy" syscalls in order to copy the file more
406+
efficiently (see :issue:`33671`).
407+
"fast-copy" means that the copying operation occurs within the kernel, avoiding
408+
the use of userspace buffers in Python as in "``outfd.write(infd.read())``".
409+
410+
On OSX `fcopyfile`_ is used to copy the file content (not metadata).
411+
412+
On Linux, Solaris and other POSIX platforms where :func:`os.sendfile` supports
413+
copies between 2 regular file descriptors :func:`os.sendfile` is used.
414+
415+
If the fast-copy operation fails and no data was written in the destination
416+
file then shutil will silently fallback on using less efficient
417+
:func:`copyfileobj` function internally.
418+
419+
.. versionchanged:: 3.8
373420

374421
.. _shutil-copytree-example:
375422

@@ -654,6 +701,8 @@ Querying the size of the output terminal
654701

655702
.. versionadded:: 3.3
656703

704+
.. _`fcopyfile`:
705+
http://www.manpagez.com/man/3/copyfile/
706+
657707
.. _`Other Environment Variables`:
658708
http://pubs.opengroup.org/onlinepubs/7908799/xbd/envvar.html#tag_002_003
659-

Doc/whatsnew/3.8.rst

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,27 @@ New Modules
9090
Improved Modules
9191
================
9292

93-
9493
Optimizations
9594
=============
9695

96+
* :func:`shutil.copyfile`, :func:`shutil.copy`, :func:`shutil.copy2`,
97+
:func:`shutil.copytree` and :func:`shutil.move` use platform-specific
98+
"fast-copy" syscalls on Linux, OSX and Solaris in order to copy the file more
99+
efficiently.
100+
"fast-copy" means that the copying operation occurs within the kernel,
101+
avoiding the use of userspace buffers in Python as in
102+
"``outfd.write(infd.read())``".
103+
All other platforms not using such technique will rely on a faster
104+
:func:`shutil.copyfile` implementation using :func:`memoryview`,
105+
:class:`bytearray` and
106+
:meth:`BufferedIOBase.readinto() <io.BufferedIOBase.readinto>`.
107+
Finally, :func:`shutil.copyfile` default buffer size on Windows was increased
108+
from 16KB to 1MB.
109+
The speedup for copying a 512MB file within the same partition is about +26%
110+
on Linux, +50% on OSX and +38% on Windows. Also, much less CPU cycles are
111+
consumed.
112+
(Contributed by Giampaolo Rodola' in :issue:`25427`.)
113+
97114
* The default protocol in the :mod:`pickle` module is now Protocol 4,
98115
first introduced in Python 3.4. It offers better performance and smaller
99116
size compared to Protocol 3 available since Python 3.0.

Lib/shutil.py

Lines changed: 145 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import fnmatch
1111
import collections
1212
import errno
13+
import io
1314

1415
try:
1516
import zlib
@@ -42,6 +43,16 @@
4243
except ImportError:
4344
getgrnam = None
4445

46+
posix = nt = None
47+
if os.name == 'posix':
48+
import posix
49+
elif os.name == 'nt':
50+
import nt
51+
52+
COPY_BUFSIZE = 1024 * 1024 if os.name == 'nt' else 16 * 1024
53+
_HAS_SENDFILE = posix and hasattr(os, "sendfile")
54+
_HAS_FCOPYFILE = posix and hasattr(posix, "_fcopyfile") # OSX
55+
4556
__all__ = ["copyfileobj", "copyfile", "copymode", "copystat", "copy", "copy2",
4657
"copytree", "move", "rmtree", "Error", "SpecialFileError",
4758
"ExecError", "make_archive", "get_archive_formats",
@@ -72,14 +83,124 @@ class RegistryError(Exception):
7283
"""Raised when a registry operation with the archiving
7384
and unpacking registries fails"""
7485

86+
class _GiveupOnFastCopy(Exception):
87+
"""Raised as a signal to fallback on using raw read()/write()
88+
file copy when fast-copy functions fail to do so.
89+
"""
90+
91+
def _fastcopy_osx(fsrc, fdst, flags):
92+
"""Copy a regular file content or metadata by using high-performance
93+
fcopyfile(3) syscall (OSX).
94+
"""
95+
try:
96+
infd = fsrc.fileno()
97+
outfd = fdst.fileno()
98+
except Exception as err:
99+
raise _GiveupOnFastCopy(err) # not a regular file
100+
101+
try:
102+
posix._fcopyfile(infd, outfd, flags)
103+
except OSError as err:
104+
err.filename = fsrc.name
105+
err.filename2 = fdst.name
106+
if err.errno in {errno.EINVAL, errno.ENOTSUP}:
107+
raise _GiveupOnFastCopy(err)
108+
else:
109+
raise err from None
110+
111+
def _fastcopy_sendfile(fsrc, fdst):
112+
"""Copy data from one regular mmap-like fd to another by using
113+
high-performance sendfile(2) syscall.
114+
This should work on Linux >= 2.6.33 and Solaris only.
115+
"""
116+
# Note: copyfileobj() is left alone in order to not introduce any
117+
# unexpected breakage. Possible risks by using zero-copy calls
118+
# in copyfileobj() are:
119+
# - fdst cannot be open in "a"(ppend) mode
120+
# - fsrc and fdst may be open in "t"(ext) mode
121+
# - fsrc may be a BufferedReader (which hides unread data in a buffer),
122+
# GzipFile (which decompresses data), HTTPResponse (which decodes
123+
# chunks).
124+
# - possibly others (e.g. encrypted fs/partition?)
125+
global _HAS_SENDFILE
126+
try:
127+
infd = fsrc.fileno()
128+
outfd = fdst.fileno()
129+
except Exception as err:
130+
raise _GiveupOnFastCopy(err) # not a regular file
131+
132+
# Hopefully the whole file will be copied in a single call.
133+
# sendfile() is called in a loop 'till EOF is reached (0 return)
134+
# so a bufsize smaller or bigger than the actual file size
135+
# should not make any difference, also in case the file content
136+
# changes while being copied.
137+
try:
138+
blocksize = max(os.fstat(infd).st_size, 2 ** 23) # min 8MB
139+
except Exception:
140+
blocksize = 2 ** 27 # 128MB
141+
142+
offset = 0
143+
while True:
144+
try:
145+
sent = os.sendfile(outfd, infd, offset, blocksize)
146+
except OSError as err:
147+
# ...in oder to have a more informative exception.
148+
err.filename = fsrc.name
149+
err.filename2 = fdst.name
150+
151+
if err.errno == errno.ENOTSOCK:
152+
# sendfile() on this platform (probably Linux < 2.6.33)
153+
# does not support copies between regular files (only
154+
# sockets).
155+
_HAS_SENDFILE = False
156+
raise _GiveupOnFastCopy(err)
157+
158+
if err.errno == errno.ENOSPC: # filesystem is full
159+
raise err from None
160+
161+
# Give up on first call and if no data was copied.
162+
if offset == 0 and os.lseek(outfd, 0, os.SEEK_CUR) == 0:
163+
raise _GiveupOnFastCopy(err)
164+
165+
raise err
166+
else:
167+
if sent == 0:
168+
break # EOF
169+
offset += sent
170+
171+
def _copybinfileobj(fsrc, fdst, length=COPY_BUFSIZE):
172+
"""Copy 2 regular file objects open in binary mode."""
173+
# Localize variable access to minimize overhead.
174+
fsrc_readinto = fsrc.readinto
175+
fdst_write = fdst.write
176+
with memoryview(bytearray(length)) as mv:
177+
while True:
178+
n = fsrc_readinto(mv)
179+
if not n:
180+
break
181+
elif n < length:
182+
fdst_write(mv[:n])
183+
else:
184+
fdst_write(mv)
185+
186+
def _is_binary_files_pair(fsrc, fdst):
187+
return hasattr(fsrc, 'readinto') and \
188+
isinstance(fsrc, io.BytesIO) or 'b' in getattr(fsrc, 'mode', '') and \
189+
isinstance(fdst, io.BytesIO) or 'b' in getattr(fdst, 'mode', '')
75190

76-
def copyfileobj(fsrc, fdst, length=16*1024):
191+
def copyfileobj(fsrc, fdst, length=COPY_BUFSIZE):
77192
"""copy data from file-like object fsrc to file-like object fdst"""
78-
while 1:
79-
buf = fsrc.read(length)
80-
if not buf:
81-
break
82-
fdst.write(buf)
193+
if _is_binary_files_pair(fsrc, fdst):
194+
_copybinfileobj(fsrc, fdst, length=length)
195+
else:
196+
# Localize variable access to minimize overhead.
197+
fsrc_read = fsrc.read
198+
fdst_write = fdst.write
199+
while 1:
200+
buf = fsrc_read(length)
201+
if not buf:
202+
break
203+
fdst_write(buf)
83204

84205
def _samefile(src, dst):
85206
# Macintosh, Unix.
@@ -117,9 +238,23 @@ def copyfile(src, dst, *, follow_symlinks=True):
117238
if not follow_symlinks and os.path.islink(src):
118239
os.symlink(os.readlink(src), dst)
119240
else:
120-
with open(src, 'rb') as fsrc:
121-
with open(dst, 'wb') as fdst:
122-
copyfileobj(fsrc, fdst)
241+
with open(src, 'rb') as fsrc, open(dst, 'wb') as fdst:
242+
if _HAS_SENDFILE:
243+
try:
244+
_fastcopy_sendfile(fsrc, fdst)
245+
return dst
246+
except _GiveupOnFastCopy:
247+
pass
248+
249+
if _HAS_FCOPYFILE:
250+
try:
251+
_fastcopy_osx(fsrc, fdst, posix._COPYFILE_DATA)
252+
return dst
253+
except _GiveupOnFastCopy:
254+
pass
255+
256+
_copybinfileobj(fsrc, fdst)
257+
123258
return dst
124259

125260
def copymode(src, dst, *, follow_symlinks=True):
@@ -244,13 +379,12 @@ def copy(src, dst, *, follow_symlinks=True):
244379

245380
def copy2(src, dst, *, follow_symlinks=True):
246381
"""Copy data and all stat info ("cp -p src dst"). Return the file's
247-
destination."
382+
destination.
248383
249384
The destination may be a directory.
250385
251386
If follow_symlinks is false, symlinks won't be followed. This
252387
resembles GNU's "cp -P src dst".
253-
254388
"""
255389
if os.path.isdir(dst):
256390
dst = os.path.join(dst, os.path.basename(src))
@@ -1015,7 +1149,6 @@ def disk_usage(path):
10151149

10161150
elif os.name == 'nt':
10171151

1018-
import nt
10191152
__all__.append('disk_usage')
10201153
_ntuple_diskusage = collections.namedtuple('usage', 'total used free')
10211154

0 commit comments

Comments
 (0)