Skip to content

Commit 2d461dd

Browse files
committed
Fetch consecutive missing blocks of streamed content in one request
fsspec's "blockcache" fetches each block of a read with its own request, so a large read (e.g., of a dataset's data, by NWB Inspector) took one round trip per 256 KiB, i.e., a couple of MB/s however fast the connection. Use instead a block cache that fetches each run of consecutive missing blocks with a single request, and keeps up to 512 blocks (128 MiB) per open file rather than 32, which h5py's scattered reads of metadata kept evicting and fetching again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
1 parent 6d23a54 commit 2d461dd

2 files changed

Lines changed: 137 additions & 2 deletions

File tree

‎dandi/support/datalad_fuse.py‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,79 @@ def annex_fingerprint(source: Any) -> tuple[str, str] | None:
191191
#: Size of the blocks in which streamed content is read and cached
192192
STREAM_BLOCK_SIZE = 256 * 1024
193193

194+
#: Maximum number of blocks cached for each open streamed file
195+
STREAM_MAX_BLOCKS = 512
196+
197+
#: Name of the fsspec cache defined by `_coalescing_block_cache()`
198+
COALESCING_BLOCK_CACHE = "dandi-coalescing-blockcache"
199+
200+
201+
@cache
202+
def _coalescing_block_cache() -> type:
203+
"""
204+
Define (once, so as not to import fsspec when this module is imported) and
205+
register with fsspec a cache that, like fsspec's ``"blockcache"``, keeps
206+
the least recently used blocks of a file, but fetches the consecutive
207+
blocks missing for a read with a single request rather than with one
208+
request per block, which would make reading a large dataset take one
209+
round trip per `STREAM_BLOCK_SIZE` bytes
210+
"""
211+
from collections import OrderedDict
212+
213+
from fsspec.caching import BaseCache, register_cache
214+
215+
class CoalescingBlockCache(BaseCache): # type: ignore[misc]
216+
name = COALESCING_BLOCK_CACHE
217+
218+
def __init__(
219+
self,
220+
blocksize: int,
221+
fetcher: Any,
222+
size: int,
223+
maxblocks: int = STREAM_MAX_BLOCKS,
224+
) -> None:
225+
super().__init__(blocksize, fetcher, size)
226+
self.maxblocks = maxblocks
227+
self.blocks: OrderedDict[int, bytes] = OrderedDict()
228+
229+
def _fetch(self, start: int | None, end: int | None) -> bytes:
230+
if start is None:
231+
start = 0
232+
if end is None or end > self.size:
233+
end = self.size
234+
if start >= end:
235+
return b""
236+
first = start // self.blocksize
237+
last = (end - 1) // self.blocksize
238+
numbers = range(first, last + 1)
239+
missing = [n for n in numbers if n not in self.blocks]
240+
# Fetch each run of consecutive missing blocks in one request
241+
i = 0
242+
while i < len(missing):
243+
j = i
244+
while j + 1 < len(missing) and missing[j + 1] == missing[j] + 1:
245+
j += 1
246+
run_start = missing[i] * self.blocksize
247+
run_end = min((missing[j] + 1) * self.blocksize, self.size)
248+
data = self.fetcher(run_start, run_end)
249+
for n in range(missing[i], missing[j] + 1):
250+
offset = (n - missing[i]) * self.blocksize
251+
self.blocks[n] = data[offset : offset + self.blocksize]
252+
i = j + 1
253+
out = []
254+
for n in numbers:
255+
self.blocks.move_to_end(n)
256+
out.append(self.blocks[n])
257+
# Evict the least recently used blocks, but not those just read
258+
while len(self.blocks) > max(self.maxblocks, len(numbers)):
259+
self.blocks.popitem(last=False)
260+
data = b"".join(out)
261+
offset = first * self.blocksize
262+
return data[start - offset : end - offset]
263+
264+
register_cache(CoalescingBlockCache, clobber=True)
265+
return CoalescingBlockCache
266+
194267

195268
def _read_in_small_blocks(dsap: Any) -> None:
196269
"""
@@ -206,7 +279,8 @@ def _read_in_small_blocks(dsap: Any) -> None:
206279
fs = getattr(dsap, "fs", None)
207280
if isinstance(fs, HTTPFileSystem):
208281
fs.block_size = STREAM_BLOCK_SIZE
209-
fs.cache_type = "blockcache"
282+
_coalescing_block_cache()
283+
fs.cache_type = COALESCING_BLOCK_CACHE
210284

211285

212286
def get_annexed_readable(path: str | Path) -> AnnexedReadableFile | None:

‎dandi/support/tests/test_datalad_fuse.py‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
from pynwb import NWBHDF5IO, NWBFile, TimeSeries
1313
import pytest
1414

15-
from ..datalad_fuse import AnnexedReadableFile, annex_fingerprint, get_annexed_readable
15+
from ..datalad_fuse import (
16+
AnnexedReadableFile,
17+
_coalescing_block_cache,
18+
annex_fingerprint,
19+
get_annexed_readable,
20+
)
1621
from ...pynwb_utils import get_neurodata_types
1722
from ...tests.fixtures import RangeHTTPServer, make_git_annex_dandiset
1823
from ...tests.skip import mark
@@ -114,6 +119,62 @@ def test_annexed_readable_reads_little(
114119
assert 0 < server.bytes_served < size / 10
115120

116121

122+
@pytest.mark.ai_generated
123+
def test_coalescing_block_cache() -> None:
124+
pytest.importorskip("fsspec")
125+
content = bytes(range(256)) * 40
126+
requests: list[tuple[int, int]] = []
127+
128+
def fetcher(start: int, end: int) -> bytes:
129+
requests.append((start, end))
130+
return content[start:end]
131+
132+
c = _coalescing_block_cache()(100, fetcher, len(content), maxblocks=5)
133+
# The blocks missing for a read are fetched with one request
134+
assert c._fetch(150, 420) == content[150:420]
135+
assert requests == [(100, 500)]
136+
# Cached blocks are not fetched again, and runs of missing blocks around
137+
# them are fetched with one request each
138+
assert c._fetch(30, 720) == content[30:720]
139+
assert requests[1:] == [(0, 100), (500, 800)]
140+
# Reads at the end of the content, and beyond
141+
assert c._fetch(10_190, 20_000) == content[10_190:]
142+
assert requests[3:] == [(10_100, 10_240)]
143+
assert c._fetch(20_000, 20_100) == b""
144+
# Only the 5 most recently used blocks are kept (but at least the blocks
145+
# of the last read)
146+
assert sorted(c.blocks) == [5, 6, 7, 101, 102]
147+
requests.clear()
148+
assert c._fetch(0, 10_240) == content
149+
assert requests == [(0, 500), (800, 10_100)]
150+
assert len(c.blocks) == 103
151+
152+
153+
@pytest.mark.ai_generated
154+
def test_annexed_readable_large_read(
155+
range_http_server: tuple[RangeHTTPServer, str], tmp_path: Path
156+
) -> None:
157+
"""A large read of streamed content is fetched with one request"""
158+
pytest.importorskip("datalad_fuse.fsspec")
159+
server, base_url = range_http_server
160+
local = tmp_path / "local.bin"
161+
content = np.random.default_rng(0).bytes(10_000_000)
162+
local.write_bytes(content)
163+
shutil.copy(local, tmp_path / "served" / "content.bin")
164+
make_git_annex_dandiset(
165+
tmp_path / "ds", {"sub-01/data.bin": (local, f"{base_url}/content.bin")}
166+
)
167+
r = get_annexed_readable(tmp_path / "ds" / "sub-01" / "data.bin")
168+
assert r is not None
169+
with r.open() as fp:
170+
fp.seek(123)
171+
assert fp.read(9_000_000) == content[123:9_000_123]
172+
assert server.ranged_requests == 1
173+
fp.seek(5_000_000)
174+
assert fp.read(100) == content[5_000_000:5_000_100]
175+
assert server.ranged_requests == 1
176+
177+
117178
@pytest.mark.ai_generated
118179
def test_annexed_readable_none(
119180
streamed_nwb: tuple[Path, RangeHTTPServer], simple2_nwb: Path, tmp_path: Path

0 commit comments

Comments
 (0)