Performance Engineering · I/O Performance

Zero-Copy Paths: What sendfile, splice, and mmap Remove

Which memory copies each zero-copy interface eliminates, which ones remain, and the page-cache, alignment, and lifetime constraints that decide whether the saving is real.

Serving a file to a socket with the obvious pair of calls moves each byte four times: from the device into a kernel buffer, from that buffer into the application’s buffer, from the application’s buffer into the socket’s transmit buffer, and from there to the network interface. Two of those copies serve no purpose other than to let the application look at bytes it is not going to change. That observation produced a family of interfaces, each of which removes a different subset of the copies, and each of which is correct only under conditions that the interface documentation states but that callers rarely check.

Counting the copies in the naive path

Take a conventional file server. It reads a chunk of the file with read, which copies from a page-cache page into the process’s buffer. It then calls write or send on the socket, which copies from the process buffer into a socket buffer as a data-carrying segment. The network stack later hands the segment to the device driver, which either copies it into a transmit ring or builds a scatter-gather list that references the socket buffer directly.

The first copy is unavoidable in this design because the application has a buffer in between. Removing the application buffer removes both the first and the second copy, provided the kernel can move data from the file’s page-cache page into the socket without a user-space address in between. That is exactly what sendfile does: it takes an input descriptor, an output descriptor, and a length, and transfers between them inside the kernel.

The remaining copies are from the page cache to the socket buffer, and from the socket buffer to the wire. If the network device supports scatter-gather and the packet headers are handled separately, the second copy can become a reference, leaving one copy for the whole path. On a machine with checksum offload, checksums are also computed by the device rather than while copying.

#include <sys/sendfile.h>
#include <sys/socket.h>
#include <unistd.h>

/* Transfer len bytes of file at off to the socket, no user-space buffer. */
ssize_t send_file_range(int file_fd, int sock_fd, off_t off, size_t len)
{
    size_t done = 0;

    while (done < len) {
        ssize_t n = sendfile(sock_fd, file_fd, &off, len - done);
        if (n < 0) {
            if (errno == EINTR || errno == EAGAIN)
                continue;          /* retry without advancing off */
            return -1;
        }
        if (n == 0)
            break;                 /* end of file */
        done += (size_t)n;
    }
    return (ssize_t)done;
}

The constraints sendfile imposes

The output must be a socket in the general case. Some kernels and some filesystems also support a regular file as the output, but portability of that form is not guaranteed, and the interface documentation states the limitation rather than leaving it to discovery.

The input must be a file that supports memory mapping, since the implementation works by mapping the file’s pages. Reading from a pipe or a terminal through sendfile does not have the same meaning. The file’s pages must be page-cache pages, so the gain is real for cached files and the cost of a miss is a device read into a page that is then sent.

The output must tolerate not being able to write everything. A non-blocking socket returns EAGAIN after transferring some bytes, and the file offset is advanced only by the amount transferred, so the retry must use the updated offset. The example above relies on that, which is why it passes a pointer to a local off_t rather than relying on the file descriptor’s own position.

sendfile also disturbs the descriptor’s file position when the offset argument is null, which makes it unusable for the same file being served to many sockets concurrently unless the offset form is used.

splice, and moving pages between kernel buffers

splice generalises the idea to pipes. It moves data between a file descriptor and a pipe without copying, by transferring references to the pages rather than the pages’ contents. The receiving side of the pipe then holds pages owned by the file’s page cache. Half of the transfer is genuinely zero-copy; the other half depends on whether the destination can accept references.

The classic construction for a server is: splice from the file into a pipe, then splice from the pipe to the socket. The first call moves page references into the pipe buffer; the second hands data from the pipe to the network stack. Whether that second step copies depends on the network stack’s ability to take references to pipe pages, which depends on the socket type and the platform.

The pipe is a real constraint. Its capacity is bounded, typically 64 KiB by default and adjustable within limits, so a large transfer becomes a sequence of fill-and-drain operations that must be interleaved with the socket writes. The pipe is not a buffer the application can afford to grow, because a pipe holding a large amount of data holds pages that cannot be reclaimed.

There is a correctness constraint too. Data in a pipe that came from a file retains a reference to the file’s pages. If the application truncates or modifies the file while the pipe still holds those pages, the data in the pipe reflects the state at the time of the splice, not the current file contents. For a static file server that never modifies served files this is irrelevant; for a server that serves files it is also editing, it is a source of torn responses.

mmap, and who pays for the copy

Mapping a file replaces reads with page faults. The kernel maps the file’s page-cache pages into the process address space at the fault, so the first access after a fault copies nothing: the process reads the same physical page the cache holds. Subsequent accesses cost nothing beyond the memory access itself. A file server can map a file and hand the mapped range to a socket write, but a socket write from a mapping still copies into the socket buffer, so mapping removes one copy and leaves the other.

The trade is that faults are the new unit of cost. Touching the first byte of each 4 KiB page causes a minor fault at roughly a microsecond on a machine with a warm page table, with the kernel entering, checking the mapping, and installing the translation. The fault count scales with the mapped range, not with the bytes actually read, which means an application that reads one byte per page from a large mapping pays the full fault cost. Huge pages reduce the count by the page size ratio where the filesystem and the hardware support it.

The lifetime rules are the real hazard. A mapped page refers to a file that may be truncated by another process, and access beyond the new end raises SIGBUS, which is not catchable in a way that lets the process resume the access. A mapped region also keeps its pages referenced, so a long-lived mapping of a growing file prevents reclaim of the file’s pages regardless of whether the process is using them.

Choosing among the three

sendfile is the right default for the case it was designed for: a static file transferred to a socket, with the page cache already holding the data. It has the least machinery and the fewest correctness traps.

splice is for cases where data must pass through a pipe anyway, or where the file’s backing is not a regular file. It costs the pipe buffer and adds the requirement to interleave source and sink operations so the pipe neither empties nor fills.

mmap is the right choice when the data will be inspected rather than only forwarded, since the mapping gives the process direct access without copying and lets repeated access be free. It is the wrong choice for a one-pass transfer of a large file, where the fault cost per page exceeds the copy it replaces.

All three depend on the page cache and on the device underneath. A zero-copy path over uncached data waits the same device latency as any other path, so the gain appears in CPU time and memory bandwidth, not in device latency. See Readahead Policy and the Read Sizes the Page Cache Actually Sees for the mechanisms that decide whether the pages are there. Where the device is the limit, direct transfers bypass the cache entirely, as covered in Alignment, O_DIRECT, and the Cost of Bypassing the Page Cache, and none of the three interfaces above applies.

What zero-copy still copies, and why the gain is usually CPU-bound

Every variant still moves bytes from memory to the device’s DMA engine’s view of memory. What they remove is redundant travelling through CPU registers and caches. The saving therefore scales with the number of bytes and inversely with the cost of a copy, which is why it matters most when the machine’s memory bandwidth or its core count is the limit rather than the storage.

The interface overhead is not removed either. A sendfile call is still a syscall with the fixed costs described in Where a read() Syscall Spends Time Before It Reaches the Data, and 64-kilobyte chunks mean 16 calls per megabyte. Raising the chunk size helps until the socket accepts less than offered; then the chunk must shrink or the call must be retried with the remaining length. The designs that remove the syscall count as well as the copies place both descriptors and buffers in a ring shared with the kernel, which is the subject of io_uring Submission and Completion Ring Mechanics.

References

  1. sendfile(2) — Linux manual page — man7.org
  2. splice(2) — Linux manual page — man7.org
  3. mmap(2) — Linux manual page — man7.org
  4. POSIX.1-2017: mmap() — The Open Group