Skip to content

Commit abd9138

Browse files
authored
Merge pull request #14 from JaneliaSciComp/performance_enhancement
file_iterator performance enhancement
2 parents 37fce5f + 97ecefd commit abd9138

File tree

2 files changed

+275
-14
lines changed

2 files changed

+275
-14
lines changed

tests/test_file_performance.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
"""
2+
Performance tests for file streaming with different buffer sizes.
3+
4+
These tests verify that larger buffer sizes improve streaming performance,
5+
especially important for network filesystems where latency dominates.
6+
"""
7+
8+
import tempfile
9+
import time
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
from x2s3.client_file import file_iterator, FileObjectHandle
15+
16+
17+
def create_test_file(size_mb: int) -> Path:
18+
"""Create a temporary test file of specified size."""
19+
tmp = tempfile.NamedTemporaryFile(delete=False)
20+
tmp_path = Path(tmp.name)
21+
22+
# Write test data in 1MB chunks
23+
chunk = b'x' * (1024 * 1024)
24+
for _ in range(size_mb):
25+
tmp.write(chunk)
26+
tmp.close()
27+
28+
return tmp_path
29+
30+
31+
def stream_file_with_buffer(file_path: Path, buffer_size: int) -> tuple[float, int]:
32+
"""
33+
Stream a file with given buffer size and return (elapsed_time, bytes_read).
34+
"""
35+
file_handle = open(file_path, 'rb')
36+
file_size = file_path.stat().st_size
37+
38+
handle = FileObjectHandle(
39+
target_name="test",
40+
key=str(file_path),
41+
status_code=200,
42+
headers={},
43+
media_type="application/octet-stream",
44+
content_length=file_size,
45+
file_handle=file_handle,
46+
start=0,
47+
end=None
48+
)
49+
50+
start_time = time.time()
51+
total_bytes = 0
52+
53+
for chunk in file_iterator(handle, buffer_size=buffer_size):
54+
total_bytes += len(chunk)
55+
56+
elapsed = time.time() - start_time
57+
58+
return elapsed, total_bytes
59+
60+
61+
@pytest.mark.parametrize("file_size_mb", [10, 50])
62+
def test_buffer_size_performance(file_size_mb):
63+
"""
64+
Test that larger buffer sizes improve streaming performance.
65+
66+
This test verifies the fix works by comparing performance across
67+
different buffer sizes. Larger buffers should be faster due to
68+
fewer read() system calls.
69+
"""
70+
test_file = create_test_file(file_size_mb)
71+
72+
try:
73+
# Test with small buffer (8KB - the old default behavior)
74+
time_8kb, bytes_8kb = stream_file_with_buffer(test_file, buffer_size=8192)
75+
76+
# Test with medium buffer (64KB)
77+
time_64kb, bytes_64kb = stream_file_with_buffer(test_file, buffer_size=64*1024)
78+
79+
# Test with large buffer (256KB - recommended for network filesystems)
80+
time_256kb, bytes_256kb = stream_file_with_buffer(test_file, buffer_size=256*1024)
81+
82+
# Verify all read the same amount
83+
expected_bytes = file_size_mb * 1024 * 1024
84+
assert bytes_8kb == expected_bytes
85+
assert bytes_64kb == expected_bytes
86+
assert bytes_256kb == expected_bytes
87+
88+
# Calculate speedups
89+
speedup_64kb = time_8kb / time_64kb if time_64kb > 0 else float('inf')
90+
speedup_256kb = time_8kb / time_256kb if time_256kb > 0 else float('inf')
91+
92+
# Print performance metrics for debugging
93+
print(f"\n=== Performance Test Results ({file_size_mb}MB file) ===")
94+
print(f"8KB buffer: {time_8kb:.4f}s ({bytes_8kb / time_8kb / (1024*1024):.2f} MB/s)")
95+
print(f"64KB buffer: {time_64kb:.4f}s ({bytes_64kb / time_64kb / (1024*1024):.2f} MB/s) - {speedup_64kb:.2f}x faster")
96+
print(f"256KB buffer: {time_256kb:.4f}s ({bytes_256kb / time_256kb / (1024*1024):.2f} MB/s) - {speedup_256kb:.2f}x faster")
97+
98+
# Assert performance improvements
99+
# Note: Even on local disk, we expect at least 20% improvement with larger buffers
100+
# On network filesystems, improvements would be 10-100x or more
101+
assert time_64kb < time_8kb, \
102+
f"64KB buffer should be faster than 8KB (8KB: {time_8kb:.4f}s, 64KB: {time_64kb:.4f}s)"
103+
104+
assert time_256kb < time_8kb, \
105+
f"256KB buffer should be faster than 8KB (8KB: {time_8kb:.4f}s, 256KB: {time_256kb:.4f}s)"
106+
107+
# Allow some tolerance for measurement noise, but expect meaningful improvement
108+
# We use a conservative 1.2x threshold to avoid flaky tests, but real improvements
109+
# should be much larger (3-5x on local disk, 100-1000x on network filesystems)
110+
assert speedup_64kb > 1.2, \
111+
f"64KB buffer should be at least 20% faster (got {speedup_64kb:.2f}x)"
112+
113+
assert speedup_256kb > 1.2, \
114+
f"256KB buffer should be at least 20% faster (got {speedup_256kb:.2f}x)"
115+
116+
finally:
117+
# Cleanup
118+
test_file.unlink()
119+
120+
121+
def test_file_iterator_respects_buffer_size():
122+
"""
123+
Test that file_iterator actually uses the buffer_size parameter.
124+
125+
This test verifies that the fix (explicit fh.read(buffer_size)) properly
126+
respects the buffer_size parameter, which is critical for performance on
127+
network filesystems where each read operation has significant overhead.
128+
"""
129+
test_file = create_test_file(5) # 5MB test file
130+
131+
try:
132+
# Test with different buffer sizes using the actual file_iterator function
133+
buffer_sizes = [8192, 64*1024, 256*1024] # 8KB, 64KB, 256KB
134+
results = []
135+
136+
for buffer_size in buffer_sizes:
137+
file_handle = open(test_file, 'rb')
138+
file_size = test_file.stat().st_size
139+
140+
handle = FileObjectHandle(
141+
target_name="test",
142+
key=str(test_file),
143+
status_code=200,
144+
headers={},
145+
media_type="application/octet-stream",
146+
content_length=file_size,
147+
file_handle=file_handle,
148+
start=0,
149+
end=None # Full file (this is where our fix applies)
150+
)
151+
152+
# Collect chunks from file_iterator
153+
chunks = []
154+
for chunk in file_iterator(handle, buffer_size=buffer_size):
155+
chunks.append(len(chunk))
156+
157+
results.append({
158+
'buffer_size': buffer_size,
159+
'chunk_count': len(chunks),
160+
'chunk_sizes': chunks,
161+
'total_bytes': sum(chunks)
162+
})
163+
164+
# Verify all read the same total data
165+
for result in results:
166+
assert result['total_bytes'] == 5 * 1024 * 1024, \
167+
f"Should read 5MB, got {result['total_bytes']}"
168+
169+
print(f"\nfile_iterator buffer_size impact:")
170+
for result in results:
171+
print(f" {result['buffer_size'] // 1024}KB buffer: {result['chunk_count']} chunks yielded")
172+
173+
# Verify larger buffers result in fewer chunks (fewer read operations)
174+
assert results[0]['chunk_count'] > results[1]['chunk_count'] > results[2]['chunk_count'], \
175+
"Larger buffers should result in fewer chunks from file_iterator"
176+
177+
# Verify the reduction is significant (our fix should achieve 10x+ reduction)
178+
reduction = results[0]['chunk_count'] / results[2]['chunk_count']
179+
print(f" Reduction with 256KB vs 8KB: {reduction:.1f}x fewer chunks")
180+
assert reduction > 10, \
181+
f"256KB buffer should result in at least 10x fewer chunks than 8KB (got {reduction:.1f}x)"
182+
183+
# Verify chunks are approximately the requested size
184+
for result in results:
185+
if result['chunk_count'] > 1:
186+
# Check all chunks except the last (which may be partial)
187+
for chunk_size in result['chunk_sizes'][:-1]:
188+
# Chunks should match the buffer_size exactly for full reads
189+
assert chunk_size == result['buffer_size'], \
190+
f"Chunk size {chunk_size} should equal buffer_size {result['buffer_size']}"
191+
192+
finally:
193+
test_file.unlink()
194+
195+
196+
def test_range_request_buffering():
197+
"""
198+
Test that range requests also benefit from proper buffering.
199+
"""
200+
test_file = create_test_file(10) # 10MB test file
201+
202+
try:
203+
# Read a range with small buffer
204+
file_handle = open(test_file, 'rb')
205+
handle_small = FileObjectHandle(
206+
target_name="test",
207+
key=str(test_file),
208+
status_code=206,
209+
headers={},
210+
media_type="application/octet-stream",
211+
content_length=1024*1024, # 1MB range
212+
file_handle=file_handle,
213+
start=0,
214+
end=1024*1024 - 1
215+
)
216+
217+
time_small, bytes_small = stream_file_with_buffer(test_file, buffer_size=8192)
218+
219+
# Read same range with large buffer
220+
file_handle = open(test_file, 'rb')
221+
handle_large = FileObjectHandle(
222+
target_name="test",
223+
key=str(test_file),
224+
status_code=206,
225+
headers={},
226+
media_type="application/octet-stream",
227+
content_length=1024*1024,
228+
file_handle=file_handle,
229+
start=0,
230+
end=1024*1024 - 1
231+
)
232+
233+
time_large, bytes_large = stream_file_with_buffer(test_file, buffer_size=256*1024)
234+
235+
# Both should read same amount
236+
assert bytes_small == bytes_large
237+
238+
# Large buffer should be faster
239+
assert time_large <= time_small, \
240+
"Larger buffer should be faster or equal for range requests"
241+
242+
finally:
243+
test_file.unlink()
244+
245+
246+
if __name__ == "__main__":
247+
# Run tests with verbose output
248+
pytest.main([__file__, "-v", "-s"])

x2s3/client_file.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
from x2s3.client import ProxyClient, ObjectHandle
1414

1515

16-
# Default buffer size for file streaming (8 KB)
17-
DEFAULT_BUFFER_SIZE = 8192
16+
# Default buffer size for file streaming (64 KB)
17+
# This provides good performance on network filesystems by reducing system calls
18+
# and network round-trips. Applications can override this when initializing
19+
# FileProxyClient with buffer_size parameter.
20+
DEFAULT_BUFFER_SIZE = 65536
1821

1922

2023
@dataclass
@@ -102,9 +105,19 @@ def file_iterator(handle: FileObjectHandle, buffer_size: int = DEFAULT_BUFFER_SI
102105
103106
Args:
104107
handle: FileObjectHandle containing file handle and range info
105-
buffer_size: Size of chunks to read at a time
108+
buffer_size: Size of chunks to read at a time. Larger buffers (64KB-256KB)
109+
significantly improve performance on network filesystems by reducing
110+
the number of read operations and network round-trips.
106111
107112
Note: The file handle is closed when iteration completes or on error.
113+
114+
Performance: Uses explicit buffered reads (fh.read(buffer_size)) instead of
115+
Python's line-based file iterator. This provides:
116+
- Predictable chunk sizes regardless of file content
117+
- Respect for the buffer_size parameter
118+
- Significant speedup on network filesystems by reducing read operations
119+
Line-based iteration is inappropriate for binary streaming and can cause
120+
memory issues with large files that lack newlines.
108121
"""
109122
is_large = handle.content_length is not None and handle.content_length >= LARGE_TRANSFER_THRESHOLD
110123
if is_large:
@@ -113,17 +126,17 @@ def file_iterator(handle: FileObjectHandle, buffer_size: int = DEFAULT_BUFFER_SI
113126
try:
114127
fh = handle.file_handle
115128
fh.seek(handle.start)
116-
if handle.end is None:
117-
yield from fh
118-
else:
119-
remaining = handle.end - handle.start + 1
120-
while remaining > 0:
121-
chunk_size = min(buffer_size, remaining)
122-
chunk = fh.read(chunk_size)
123-
if not chunk:
124-
break
125-
yield chunk
126-
remaining -= len(chunk)
129+
# handle.content_length contains the number of bytes to read:
130+
# - For full file reads: content_length = file_size
131+
# - For range requests: content_length = end - start + 1
132+
remaining = handle.content_length
133+
while remaining > 0:
134+
chunk_size = min(buffer_size, remaining)
135+
chunk = fh.read(chunk_size)
136+
if not chunk:
137+
break
138+
yield chunk
139+
remaining -= len(chunk)
127140
completed = True
128141
if is_large:
129142
logger.info(f"Large stream done: target={handle.target_name}, key={handle.key}, content_length={handle.content_length}")

0 commit comments

Comments
 (0)