Skip to content

Commit 436bb2a

Browse files
committed
fileio: coalesce --sparse writes instead of 1-KiB dribbles (issue #773)
write_file()'s sparse path sliced each span into SPARSE_WRITE_SIZE (1024-byte) pieces and write_sparse() issued one write() syscall per slice. Copying a large *non-sparse* file with --sparse therefore cost roughly one write() per kilobyte -- about a million write() calls for a 1 GiB file -- which on real storage ran far slower than the same copy without --sparse (the bug report measured 1.36 MB/s vs 391 MB/s, ~280x). The 1024-byte chunk is also smaller than a filesystem block, so it cannot even create finer holes than a plain copy could. Rewrite write_sparse() to scan the whole span itself: it looks for interior runs of zeros that are at least SPARSE_WRITE_SIZE long -- the same hole granularity rsync has always used -- and emits each intervening non-zero region (which may include shorter zero runs not worth a hole) with a single write(). do_punch_hole() advances the file offset just like the lseek() path, so flushing a deferred hole between segments keeps the position correct. The hole granularity is unchanged, so sparseness is identical; only the syscall pattern changes. Measured on a 100 MiB random (hole-free) file: write() syscalls drop from 100,730 to 6,125 (~16x), now tracking the data's natural chunking rather than its size in kilobytes. Verified byte-identical and equally sparse output for hole-free, large-hole, small-interior-hole, all-zero, --inplace, and --preallocate cases. testsuite/sparse-write-count_test.py copies a 16 MiB hole-free file under strace and asserts the write() count stays far below the old size/1024 behaviour (it skips where strace is unavailable).
1 parent e6956e0 commit 436bb2a

2 files changed

Lines changed: 148 additions & 25 deletions

File tree

fileio.c

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,47 @@ int sparse_end(int f, OFF_T size, int updating_basis_or_equiv)
7575
/* Note that the offset is just the caller letting us know where
7676
* the current file position is in the file. The use_seek arg tells
7777
* us that we should seek over matching data instead of writing it. */
78+
/* Flush any deferred run of zero bytes as a hole, advancing the file
79+
* position past it (both do_lseek() and do_punch_hole() move the offset). */
80+
static int flush_sparse_hole(int f)
81+
{
82+
if (!sparse_seek)
83+
return 0;
84+
if (sparse_past_write >= preallocated_len) {
85+
if (do_lseek(f, sparse_seek, SEEK_CUR) < 0) {
86+
sparse_seek = 0;
87+
return -1;
88+
}
89+
} else if (do_punch_hole(f, sparse_past_write, sparse_seek) < 0) {
90+
sparse_seek = 0;
91+
return -1;
92+
}
93+
sparse_seek = 0;
94+
return 0;
95+
}
96+
97+
static int full_sparse_write(int f, const char *buf, int len)
98+
{
99+
while (len > 0) {
100+
int ret = write(f, buf, len);
101+
if (ret <= 0) {
102+
if (ret < 0 && errno == EINTR)
103+
continue;
104+
sparse_seek = 0;
105+
return -1;
106+
}
107+
buf += ret;
108+
len -= ret;
109+
}
110+
return 0;
111+
}
112+
78113
static int write_sparse(int f, int use_seek, OFF_T offset, const char *buf, int len)
79114
{
80-
int l1 = 0, l2 = 0;
81-
int ret;
115+
int l1, l2, i, start, end;
82116

117+
/* Always treat a leading and trailing run of zeros as a (deferred)
118+
* hole, since they may merge with holes in the adjacent write calls. */
83119
for (l1 = 0; l1 < len && buf[l1] == 0; l1++) {}
84120
for (l2 = 0; l2 < len-l1 && buf[len-(l2+1)] == 0; l2++) {}
85121

@@ -88,37 +124,58 @@ static int write_sparse(int f, int use_seek, OFF_T offset, const char *buf, int
88124
if (l1 == len)
89125
return len;
90126

91-
if (sparse_seek) {
92-
if (sparse_past_write >= preallocated_len) {
93-
if (do_lseek(f, sparse_seek, SEEK_CUR) < 0)
94-
return -1;
95-
} else if (do_punch_hole(f, sparse_past_write, sparse_seek) < 0) {
96-
sparse_seek = 0;
97-
return -1;
98-
}
99-
}
100-
sparse_seek = l2;
101-
sparse_past_write = offset + len - l2;
102-
103127
if (use_seek) {
104-
/* The in-place data already matches. */
128+
/* The in-place data already matches, so just flush any pending
129+
* hole and seek over the middle without rescanning it. */
130+
if (flush_sparse_hole(f) < 0)
131+
return -1;
132+
sparse_seek = l2;
133+
sparse_past_write = offset + len - l2;
105134
if (do_lseek(f, len - (l1+l2), SEEK_CUR) < 0)
106135
return -1;
107136
return len;
108137
}
109138

110-
while ((ret = write(f, buf + l1, len - (l1+l2))) <= 0) {
111-
if (ret < 0 && errno == EINTR)
139+
/* Scan the middle [l1, len-l2) for interior runs of zeros that are at
140+
* least SPARSE_WRITE_SIZE long (the same hole granularity rsync has
141+
* always used). Each non-zero span -- which may include shorter zero
142+
* runs not worth a hole -- is emitted with a single write() rather than
143+
* being chopped into SPARSE_WRITE_SIZE-byte writes, which made a copy of
144+
* a large non-sparse file issue ~one write() syscall per KiB. */
145+
start = l1;
146+
end = len - l2;
147+
for (i = l1; i < end; ) {
148+
int z;
149+
if (buf[i] != 0) {
150+
i++;
112151
continue;
113-
sparse_seek = 0;
114-
return ret;
152+
}
153+
for (z = 1; i + z < end && buf[i+z] == 0; z++) {}
154+
if (z < SPARSE_WRITE_SIZE) {
155+
i += z;
156+
continue;
157+
}
158+
if (i > start) {
159+
if (flush_sparse_hole(f) < 0)
160+
return -1;
161+
if (full_sparse_write(f, buf + start, i - start) < 0)
162+
return -1;
163+
sparse_past_write = offset + i;
164+
}
165+
sparse_seek += z;
166+
i += z;
167+
start = i;
115168
}
116-
117-
if (ret != (int)(len - (l1+l2))) {
118-
sparse_seek = 0;
119-
return l1+ret;
169+
if (end > start) {
170+
if (flush_sparse_hole(f) < 0)
171+
return -1;
172+
if (full_sparse_write(f, buf + start, end - start) < 0)
173+
return -1;
120174
}
121175

176+
sparse_seek = l2;
177+
sparse_past_write = offset + len - l2;
178+
122179
return len;
123180
}
124181

@@ -153,8 +210,10 @@ int write_file(int f, int use_seek, OFF_T offset, const char *buf, int len)
153210
while (len > 0) {
154211
int r1;
155212
if (sparse_files > 0) {
156-
int len1 = MIN(len, SPARSE_WRITE_SIZE);
157-
r1 = write_sparse(f, use_seek, offset, buf, len1);
213+
/* write_sparse() handles the whole span itself, scanning
214+
* for holes and coalescing the non-zero data into large
215+
* write()s instead of SPARSE_WRITE_SIZE-byte dribbles. */
216+
r1 = write_sparse(f, use_seek, offset, buf, len);
158217
offset += r1;
159218
} else {
160219
if (!wf_writeBuf) {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env python3
2+
"""Regression test for --sparse write amplification (issue #773).
3+
4+
write_file()'s sparse path used to hand the data to write_sparse() in
5+
SPARSE_WRITE_SIZE (1024-byte) slices, and write_sparse() issued a separate
6+
write() syscall for each slice. Copying a large *non-sparse* file with
7+
--sparse therefore cost roughly one write() per kilobyte -- e.g. ~1,000,000
8+
write() calls for a 1 GiB file -- which on real storage ran 100x+ slower than
9+
the same copy without --sparse (1.36 MB/s vs 391 MB/s in the bug report).
10+
11+
write_sparse() now scans each span for hole-worthy zero runs itself and emits
12+
each non-zero region with a single write(), so the syscall count tracks the
13+
data's natural chunking instead of its size in kilobytes.
14+
15+
We copy a 16 MiB random (hole-free) file with --sparse under strace and assert
16+
the number of write() syscalls is far below the old size/1024 behaviour. The
17+
test is skipped where strace is unavailable or non-functional (e.g. non-Linux
18+
or a sandbox that blocks ptrace).
19+
"""
20+
21+
import os
22+
import shutil
23+
import subprocess
24+
25+
from rsyncfns import (
26+
FROMDIR, TODIR, rmtree, rsync_argv, test_fail, test_skipped,
27+
)
28+
29+
FILESIZE = 16 * 1024 * 1024
30+
# Old code: FILESIZE/1024 ~= 16384 write()s. New code: a few hundred.
31+
# Anything below FILESIZE/8192 (~2048) cleanly separates the two.
32+
MAX_WRITES = FILESIZE // 8192
33+
34+
strace = shutil.which('strace')
35+
if not strace:
36+
test_skipped("strace not available")
37+
38+
rmtree(FROMDIR)
39+
rmtree(TODIR)
40+
FROMDIR.mkdir(parents=True, exist_ok=True)
41+
TODIR.mkdir(parents=True, exist_ok=True)
42+
43+
data = os.urandom(FILESIZE) # random => no zero runs => maximal write pressure
44+
(FROMDIR / 'big').write_bytes(data)
45+
46+
argv = [strace, '-f', '-e', 'trace=write', '-o', '/dev/stdout',
47+
*rsync_argv('-a', '--sparse', f'{FROMDIR}/', f'{TODIR}/')]
48+
proc = subprocess.run(argv, capture_output=True, text=True)
49+
50+
# Make sure strace itself worked (some sandboxes deny ptrace).
51+
if proc.returncode != 0 and 'write(' not in proc.stdout:
52+
test_skipped(f"strace could not trace rsync: {proc.stderr.strip()[:200]}")
53+
54+
writes = proc.stdout.count('write(')
55+
56+
if not (TODIR / 'big').is_file() or (TODIR / 'big').read_bytes() != data:
57+
test_fail("--sparse copy produced wrong file contents")
58+
59+
if writes > MAX_WRITES:
60+
test_fail(f"--sparse made {writes} write() syscalls for a {FILESIZE}-byte "
61+
f"file (> {MAX_WRITES}); write_sparse() is dribbling out "
62+
f"SPARSE_WRITE_SIZE chunks (issue #773 regression)")
63+
64+
print(f"OK: --sparse copy used {writes} write() syscalls (<= {MAX_WRITES})")

0 commit comments

Comments
 (0)