When extracting a contiguous regular member from an uncompressed local regular archive, copy its data directly to the output file with copy_file_range(). This avoids routing data through userspace and can share extents on filesystems that support it.
Use an explicit source offset so archive read-ahead remains undisturbed, and advance tar's logical input position only after the complete copy. Fall back to buffered I/O when no bytes were copied and the syscall or filesystem does not support the operation. Keep buffered extraction for compressed, remote, multi-volume and pipe inputs, sparse members, command output, non-regular stdout, and when checkpoint actions must run per record. Import gnulib's copy-file-range module, document the optimization, and add coverage for aligned multi-record members, selected-member offsets, overwrite truncation, redirected stdout, checkpoints, pipes, and compressed archives. Signed-off-by: Daan De Meyer <[email protected]> --- NEWS | 7 +++++ doc/tar.texi | 10 +++++++ gnulib.modules | 1 + src/buffer.c | 71 ++++++++++++++++++++++++++++++++++++++++++++++ src/common.h | 9 ++++++ src/extract.c | 27 +++++++++++++++++- tests/Makefile.am | 1 + tests/extrac35.at | 71 ++++++++++++++++++++++++++++++++++++++++++++++ tests/testsuite.at | 1 + 9 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 tests/extrac35.at diff --git a/NEWS b/NEWS index 2dbdc243..5a3cff8f 100644 --- a/NEWS +++ b/NEWS @@ -13,6 +13,13 @@ in the uncompressed archive stream. SIZE must be a power of two and a multiple of 512, and cannot exceed 1 GiB. This can make extraction via reflinks or copy_file_range(2) more efficient. +* Faster extraction from regular archive files + +When extracting a contiguous regular file from an uncompressed local archive, +tar now uses copy_file_range(2) where possible. This avoids copying file data +through userspace and can share extents on file systems that support reflinks. +Tar falls back to buffered I/O when the fast path is unavailable. + * New options: --set-mtime-command and --set-mtime-format Both options are valid when archiving files. diff --git a/doc/tar.texi b/doc/tar.texi index 91632552..75eb9275 100644 --- a/doc/tar.texi +++ b/doc/tar.texi @@ -1708,6 +1708,16 @@ You can extract a file to standard output by combining the above options with the @option{--to-stdout} (@option{-O}) option (@pxref{Writing to Standard Output}). +@cindex copy_file_range +When extracting a contiguous regular file from an uncompressed archive that +is itself a local regular file, @GNUTAR{} attempts to copy the file data +directly with @code{copy_file_range}. On file systems that support it, this +can share the underlying extents instead of copying their contents. If the +operation is unavailable, @GNUTAR{} transparently falls back to buffered I/O. +This also applies to @option{--to-stdout} when standard output is redirected +to a regular file. Compressed archives, pipes, sparse members, checkpoint +actions, and extraction to a command use buffered I/O. + If you give the @option{--verbose} option, then @option{--extract} will print the names of the archive members as it extracts them. diff --git a/gnulib.modules b/gnulib.modules index 0dc024fe..52555475 100644 --- a/gnulib.modules +++ b/gnulib.modules @@ -32,6 +32,7 @@ c32tolower c32toupper closeout configmake +copy-file-range dirname dup2 errno-h diff --git a/src/buffer.c b/src/buffer.c index a48547d6..e8ebcec7 100644 --- a/src/buffer.c +++ b/src/buffer.c @@ -593,6 +593,77 @@ current_block_ordinal (void) return record_start_block + (current_block - record_start); } +/* Copy SIZE bytes starting at the current logical archive position directly + to FD. This can share extents when the input and output file systems + support it. Leave the logical archive position unchanged; the caller must + advance it after a successful copy. + + Return ARCHIVE_COPY_UNAVAILABLE if a buffered copy should be used instead, + ARCHIVE_COPY_OK on success, ARCHIVE_COPY_TRUNCATED if the source ends after + a partial copy, and ARCHIVE_COPY_ERROR if an error occurred after copying + began or cannot usefully be retried with buffered I/O. */ +enum archive_copy_status +archive_copy_file_range (int fd, off_t size) +{ + static bool unsupported; + struct stat st; + off_t input_offset; + off_t remaining = size; + bool copied = false; + + if (size <= 0 + || unsupported + || !seekable_archive + || checkpoint_option + || multi_volume_option + || use_compress_program_option + || _isrmt (archive) + || !S_ISREG (archive_stat.st_mode) + || fstat (fd, &st) != 0 + || !S_ISREG (st.st_mode) + || ckd_mul (&input_offset, current_block_ordinal (), BLOCKSIZE) + || ckd_add (&input_offset, input_offset, start_offset)) + return ARCHIVE_COPY_UNAVAILABLE; + + while (remaining != 0) + { + size_t chunk = min ((uintmax_t) remaining, + min (SSIZE_MAX, SIZE_MAX)); + ssize_t n = copy_file_range (archive, &input_offset, fd, NULL, + chunk, 0); + + if (0 < n) + { + copied = true; + remaining -= n; + continue; + } + if (n < 0 && errno == EINTR) + continue; + + if (!copied + && (n == 0 + || errno == ENOSYS + || errno == EXDEV + || errno == EINVAL + || errno == EBADF + || errno == EPERM + || errno == ENOTSUP + || (EOPNOTSUPP != ENOTSUP && errno == EOPNOTSUPP))) + { + if (n < 0 && errno == ENOSYS) + unsupported = true; + return ARCHIVE_COPY_UNAVAILABLE; + } + + if (n == 0) + return ARCHIVE_COPY_TRUNCATED; + return ARCHIVE_COPY_ERROR; + } + + return ARCHIVE_COPY_OK; +} + /* If the EOF flag is set, reset it, as well as current_block, etc. */ void reset_eof (void) diff --git a/src/common.h b/src/common.h index f4d31be8..e78afc91 100644 --- a/src/common.h +++ b/src/common.h @@ -464,6 +464,15 @@ char *drop_volume_label_suffix (const char *label) idx_t available_space_after (union block *pointer); off_t current_block_ordinal (void); + +enum archive_copy_status + { + ARCHIVE_COPY_UNAVAILABLE, + ARCHIVE_COPY_OK, + ARCHIVE_COPY_TRUNCATED, + ARCHIVE_COPY_ERROR + }; +enum archive_copy_status archive_copy_file_range (int fd, off_t size); void close_archive (void); void closeout_volume_number (void); double compute_duration_ns (void); diff --git a/src/extract.c b/src/extract.c index 5cc3ccd6..5fa181f3 100644 --- a/src/extract.c +++ b/src/extract.c @@ -1369,7 +1369,31 @@ extract_file (char *file_name, char typeflag) if (current_stat_info.is_sparse) sparse_extract_file (fd, ¤t_stat_info, &size); else - for (size = current_stat_info.stat.st_size; size > 0; ) + { + size = current_stat_info.stat.st_size; + + /* For a local, uncompressed regular archive, let the kernel copy the + member directly into a regular output file. On file systems that + support it this can share the underlying extents. */ + enum archive_copy_status copy_status + = archive_copy_file_range (fd, size); + + if (copy_status == ARCHIVE_COPY_OK) + { + skim_file (size, false); + size = 0; + } + else if (copy_status == ARCHIVE_COPY_TRUNCATED) + { + paxerror (0, _("Unexpected EOF in archive")); + } + else if (copy_status == ARCHIVE_COPY_ERROR) + { + paxerror (errno, _("%s: copy_file_range failed"), + quote (file_name)); + } + + for (; copy_status == ARCHIVE_COPY_UNAVAILABLE && size > 0; ) { mv_size_left (size); @@ -1401,6 +1425,7 @@ extract_file (char *file_name, char typeflag) break; } } + } skim_file (size, false); current_stat_info.skipped = true; diff --git a/tests/Makefile.am b/tests/Makefile.am index b06abbfa..1d7578e1 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -145,6 +145,7 @@ TESTSUITE_AT = \ extrac32.at\ extrac33.at\ extrac34.at\ + extrac35.at\ filerem01.at\ filerem02.at\ filerem03.at\ diff --git a/tests/extrac35.at b/tests/extrac35.at new file mode 100644 index 00000000..e8b22c90 --- /dev/null +++ b/tests/extrac35.at @@ -0,0 +1,71 @@ +# Test copy_file_range extraction. -*- autotest -*- + +# Copyright 2026 Free Software Foundation, Inc. + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3, or (at your option) +# any later version. + +AT_SETUP([copy_file_range extraction]) +AT_KEYWORDS([extract extrac35 copy_file_range reflink align]) + +AT_CHECK([ +mkdir input local selected checkpoint pipe +genfile --file=input/large --length=70001 +genfile --file=input/block --length=131072 +genfile --file=input/small --length=513 + +# A one-block record makes each successful direct copy cross many input +# records. Alignment also exercises the PAX padding used to make extent +# sharing effective. +tar -C input --align=4096 -b1 -cf archive.tar large block small || exit 1 +tar -C local -b1 -xf archive.tar || exit 1 +cmp input/large local/large || exit 1 +cmp input/block local/block || exit 1 +cmp input/small local/small || exit 1 + +# The source offset must remain correct after skipping an earlier member, and +# an existing destination must be truncated before the direct copy. +tar -C selected -b1 -xf archive.tar block || exit 1 +cmp input/block selected/block || exit 1 +genfile --file=local/large --length=140000 || exit 1 +tar -C local -b1 --overwrite -xf archive.tar large || exit 1 +cmp input/large local/large || exit 1 + +# Checkpoints are tied to input records, so they disable direct copying. +tar -C checkpoint -b1 --checkpoint=1 -xf archive.tar 2>checkpoints || exit 1 +test $(wc -l < checkpoints) -gt 100 || exit 1 +cmp input/large checkpoint/large || exit 1 +cmp input/block checkpoint/block || exit 1 +cmp input/small checkpoint/small || exit 1 + +# Standard output is eligible when redirected to a regular file. A pipe is +# rejected by the destination check and uses buffered I/O. +cat input/large input/small > expected-stdout || exit 1 +tar -b1 -xOf archive.tar large small > stdout-file || exit 1 +cmp expected-stdout stdout-file || exit 1 +tar -b1 -xOf archive.tar block | cmp - input/block || exit 1 + +# A pipe is not a copy_file_range source and must use buffered I/O. +cat archive.tar | tar -C pipe -b1 -B -xf - || exit 1 +cmp input/large pipe/large || exit 1 +cmp input/block pipe/block || exit 1 +cmp input/small pipe/small +], [0], [], []) + +AT_CLEANUP + +AT_SETUP([copy_file_range compressed fallback]) +AT_KEYWORDS([extract extrac35 copy_file_range gzip]) + +AT_CHECK([ +AT_GZIP_PREREQ +mkdir input output +genfile --file=input/file --length=70001 +tar -C input -czf archive.tar.gz file || exit 1 +tar -C output -xzf archive.tar.gz || exit 1 +cmp input/file output/file +], [0], [], []) + +AT_CLEANUP diff --git a/tests/testsuite.at b/tests/testsuite.at index f046adb6..460fa908 100644 --- a/tests/testsuite.at +++ b/tests/testsuite.at @@ -361,6 +361,7 @@ m4_include([extrac31.at]) m4_include([extrac32.at]) m4_include([extrac33.at]) m4_include([extrac34.at]) +m4_include([extrac35.at]) m4_include([backup01.at]) -- 2.54.0
