# Unexpected sendFile() performance behavior

**URL:** <https://ziggit.dev/t/unexpected-sendfile-performance-behavior/18141>\
**Category:** Explain\
**Tags:** standard-library, performance, operating-system\
**Created:** [October 29, 2026, 12:28pm UTC](https://ziggit.dev/t/unexpected-sendfile-performance-behavior/18141 "2026-10-29T12:28:14Z")\
**Posts on this page:** 4\
**Page:** 1

<div class="post-metadata">

**Author:** ![Bobvan](https://ziggit.dev/user_avatar/ziggit.dev/bobvan/32/5105_2.png) [@Bobvan](https://ziggit.dev/u/Bobvan)\
**Post date:** [October 29, 2026, 12:28pm UTC](https://ziggit.dev/t/unexpected-sendfile-performance-behavior/18141/1 "2026-10-29T12:28:14Z")

</div>

Hello,  
I have been playing around with implementing cat-like utility (mostly to help other people learn).  
While doing so i encountered interesting performance characteristic of sendFile() on linux.

 ![image](https://ziggit.dev/uploads/default/original/2X/e/ef97d643fa1a2adb13be06329aadb353100d14a5.jpeg)  
I will focus on the static builds, libC builds just link libC and use its allocator.

I am cating a 0.5 GiB file with stdout redirected to a file.  
I checked that the code does as expected and counted the syscalls used.

 ![image](https://ziggit.dev/uploads/default/original/2X/1/191cdc7eb5babb577f9cfc53abeab0ec1e2c11a6.png)  
The `copy_file_range` is a bit suspicious, but I have no idea where it came from.

Code:

```Zig
const std = @import("std");

// this is not 1:1 mapping of the C code, you could do that but its more common to go the cross platfrorm way
// this implementation will work on single threaded blocking code or with green threads executor (just change the io)
pub fn main(init: std.process.Init.Minimal) !void {
    // create a threaded io
    var single_threaded = std.Io.Threaded.init_single_threaded;
    const io = single_threaded.io();
    // this is really bad general purpose allocator, but its the best we have for single-threaded builds
    const gpa = std.heap.page_allocator;

    // allocate the args as we itterate, on linux it does nothing but for example windows needs them allocated
    var it = try init.args.iterateAllocator(gpa);
    // dont forget to free them
    defer it.deinit();

    // skip the executable name
    _ = it.skip();
    // get the first next arguemnt
    var arg: ?[:0]const u8 = it.next();
    // check if we got any (without this check we could have simpler while below, but no real perf lost here)
    if (arg == null) return error.NoArgumentGiven;

    // setup the buffer for writing (read per 16384 byte chunks), only relevant on sendFile fallback
    var stdout_buf: [16384]u8 = undefined;
    // setup the buffer for reading (read per 16384 byte chunks)
    var file_buf: [16384]u8 = undefined;

    // cwd is static, does not need to be closed, check its doc comment
    const local_dir: std.Io.Dir = .cwd();

    // while we get an argument, unwrap it from optional to name, at the end of loop get next
    while (arg) |name| : (arg = it.next()) {
        // open the file in cwd as read only
        const file = try local_dir.openFile(io, name, .{ .mode = .read_only });
        // dont forget to close it
        defer file.close(io);

        // create the reader backed by the buffer
        var file_reader = file.reader(io, &file_buf);
        // pull out the reader interface
        const reader = &file_reader.interface;

        // create the writer backed by the buffer (you dont close stdout, same as C)
        var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buf);
        // pull out the writer interface
        const writer = &stdout_writer.interface;

        // just let it stream the data from one another till it hits EOF, discard the ammount transfered
        _ = try reader.streamRemaining(writer);
        // dont forget to flush
        try writer.flush();
    }
}

```

```Zig
const std = @import("std");

// this is not 1:1 mapping of the C code, you could do that but its more common to go the cross platfrorm way
// this implementation will work on single threaded blocking code or with green threads executor (just change the io)
pub fn main(init: std.process.Init.Minimal) !void {
    // create a threaded io
    var single_threaded = std.Io.Threaded.init_single_threaded;
    const io = single_threaded.io();
    // this is really bad general purpose allocator, but its the best we have for single-threaded builds
    const gpa = std.heap.page_allocator;

    // allocate the args as we itterate, on linux it does nothing but for example windows needs them allocated
    var it = try init.args.iterateAllocator(gpa);
    // dont forget to free them
    defer it.deinit();

    // skip the executable name
    _ = it.skip();
    // get the first next arguemnt
    var arg: ?[:0]const u8 = it.next();
    // check if we got any (without this check we could have simpler while below, but no real perf lost here)
    if (arg == null) return error.NoArgumentGiven;

    // setup the buffer for writing (read per 16384 byte chunks), only relevant on sendFile fallback
    var stdout_buf: [16384]u8 = undefined;

    // while we get an argument, unwrap it from optional to name, at the end of loop get next
    while (arg) |name| : (arg = it.next()) {
        // cwd is static, does not need to be closed, check its doc comment
        const local_dir: std.Io.Dir = .cwd();
        // open the file in cwd as read only
        const file = try local_dir.openFile(io, name, .{ .mode = .read_only });
        // dont forget to close it
        defer file.close(io);

        // create the writer backed by the buffer (you dont close stdout, same as C) stdout is streaming FD, so you save 1 syscall before it figures it out
        var stdout_writer = std.Io.File.stdout().writerStreaming(io, &stdout_buf);
        // pull out the writer interface
        const writer = &stdout_writer.interface;

        // create the reader backed by no buffer, the sendfile fills the writer buffer directly
        var file_reader = file.reader(io, &.{});
        // no need to pull out the reader interface, sendFile takes the File.Reader not Io.Reader

        // this attempts to use more optimal syscalls, if not aviable it falls back to streaming (single buffered one tho.)
        // just let it stream the data from one another till it hits EOF, discard the ammount transfered
        _ = try writer.sendFileAll(&file_reader, .unlimited);
        // dont forget to flush
        try writer.flush();
    }
}

```

Its compiled with ReleaseFast, native architecture and:

```Zig
.single_threaded = true,
.unwind_tables = .none,
.strip = true,

```

Binaries are basically the same size aswell, the sendFIle being larger by like 580 bytes.

My expectation would be that the sendFile would be faster, anyone have any ideas why is it like this?

Robert 😊

---

<div class="post-metadata">

**Author:** ![rpkak](https://ziggit.dev/letter_avatar_proxy/v4/letter/r/a4c791/32.png) [@rpkak](https://ziggit.dev/u/rpkak)\
**Post date:** [October 29, 2026, 3:35pm UTC](https://ziggit.dev/t/unexpected-sendfile-performance-behavior/18141/2 "2026-10-29T15:35:03Z")

</div>

I don’t think there should be a big difference between `_ = try reader.streamRemaining(writer);` and `_ = try writer.sendFileAll(&file_reader, .unlimited);`. The `stream` function of a file reader just calls the `sendFile` function of the writer.

The difference is, if the writer is streaming (`File.stdout().writerStreaming()`) of positional (`File.stdout().writer()`).  
A positional file writer first tries to use the `copy_file_range` syscall. If this fails, it either changes to a streaming file writer or a loop of `[p]write[v]` and `[p]read[v]` is used (this is slow). Which of these two options is done depends on the error. You probably get a `EINVAL`, because stdout is not regular file. For `EINVAL`, `std.Io.Threaded` sets a flag to not use `copy_file_range` anymore and returns (For `OPNOTSUPP`, `INVAL` and `NOSYS`, it [sets this flag for the whole `Io.Threaded`](https://codeberg.org/ziglang/zig/src/commit/af99273c8cabe53d68cb37fae962002fb7af634e/lib/std/Io/Threaded.zig#L11775-L11781), which seems wrong to me for `OPNOTSUPP` and `INVAL`, since these errors only indicate, that the specific file/file-system does not support `copy_file_range`). After this, a loop used.

If this `fileWriteFilePositional` would return a `error.Unseekable`, the positional writer would [turn into a streaming writer](https://codeberg.org/ziglang/zig/src/commit/af99273c8cabe53d68cb37fae962002fb7af634e/lib/std/Io/File/Writer.zig#L149-L160).

A streaming file writer first tries to use the `sendfile`, which is probably the correct syscall for you to use.

Others might disagree, but I would always use a streaming, if no seeking is required.

---

<div class="post-metadata">

**Author:** ![Bobvan](https://ziggit.dev/user_avatar/ziggit.dev/bobvan/32/5105_2.png) [@Bobvan](https://ziggit.dev/u/Bobvan)\
**Post date:** [October 29, 2026, 4:19pm UTC](https://ziggit.dev/t/unexpected-sendfile-performance-behavior/18141/3 "2026-10-29T16:19:52Z")

</div>

Well all that streaming and non streaming writer/reader setup only affects whether the `sendfile` syscall is used. I dont care about that part tho.

Thank you for clarification why `copy_file_range` happened.

I know that when i force it to not use `sendfile` syscall it gets much faster, my question is why is kernel level `sendfile` so much slower than the 16k reads and writes.

My expectation would be that its an possible optimization path, not a regression if it happens.

---

<div class="post-metadata">

**Author:** ![lalinsky](https://ziggit.dev/user_avatar/ziggit.dev/lalinsky/32/3720_2.png) [@lalinsky](https://ziggit.dev/u/lalinsky)\
**Post date:** [October 29, 2026, 4:54pm UTC](https://ziggit.dev/t/unexpected-sendfile-performance-behavior/18141/4 "2026-10-29T16:54:00Z")

</div>

I was looking at this, because it’s interesting, and found that it’s only slower if I leave the old file on disk so the benchmark has to overwrite it. If you delete it across runs, the sendfile version is slightly faster. It seems to have something to do with the timing of page invalidation. I have a suspicion, not measured yet, that the sendfile variant is actually always faster, and then it hangs in program exit, flushing the pages dirtied the sendfile.
