Skip to content

Commit 11602e6

Browse files
FrancescAltedclaude
andcommitted
Analyse and measure block-granular fsspec downloads
lazy=True fetches a whole compressed chunk per range request. Blocks are the smaller unit blosc2 already compresses independently, so a slice could fetch only the ones it touches. plans/fsspec-blocks.md works out what that would take and what it would buy. The format cooperates more than expected, and the analysis is grounded in probes rather than in the format docs: a block decodes standalone inside a synthetic one-block chunk; a chunk holding only some of its blocks, with csize == 0 zero streams standing in for the rest, is valid and update_chunk accepts it, so partial chunks can live in the cache with no compression on the fetch path; and bstarts is not monotonic (a multithreaded compressor writes blocks in completion order), so extents need the sorted-neighbour rule the chunk reader already uses. Measured against real S3 with the new benchmark, which computes exact touch ratios locally and then replays both request patterns over the network: 5-17x on arrays with multi-MB chunks, 2-5x on 1 MB chunks, and 0.5-0.7x -- one extra round trip -- on small ones or on slices that want most of their blocks anyway. Break-even is around 1 MB of compressed chunk, and it barely moves with the endpoint. Default block shapes are full in the trailing dimensions, so a column touches every block of every chunk it touches; the whole-chunk fallback is part of the design rather than a refinement of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b6975fd commit 11602e6

2 files changed

Lines changed: 729 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
#!/usr/bin/env python
2+
3+
#######################################################################
4+
# Copyright (c) 2019-present, Blosc Development Team <blosc@blosc.org>
5+
# All rights reserved.
6+
#
7+
# SPDX-License-Identifier: BSD-3-Clause
8+
#######################################################################
9+
10+
"""Would block-granular downloads beat chunk-granular ones for a given array?
11+
12+
``blosc2.open(url, lazy=True)`` fetches one whole compressed chunk per range
13+
request. A chunk is made of blocks, which blosc2 compresses and decompresses
14+
independently, so a slice could in principle fetch only the blocks it touches.
15+
Whether that is worth the extra round trip it costs (the block offsets live in
16+
the chunk header, which has to be read first) depends on two numbers this script
17+
measures:
18+
19+
- the **touch ratio**: what fraction of the bytes of the chunks a slice touches
20+
its blocks actually account for. Exact, computed locally from the array's own
21+
chunk headers, no network involved;
22+
- the **wall time** of the two request patterns against a real object store.
23+
24+
Usage
25+
-----
26+
# touch ratios only, on any local array
27+
python fsspec-block-granularity.py mydata.b2nd
28+
29+
# ... and time both request patterns against real S3
30+
python fsspec-block-granularity.py mydata.b2nd \\
31+
--replay s3://noaa-goes16/ABI-L1b-RadF/2020/001/00/OR_ABI-L1b-RadF-M6C02_G16_s20200010000216_e20200010009524_c20200010009570.nc --anon
32+
33+
The replay target is *any* object at least as large as the biggest request; its
34+
contents are never used. What is being timed is the request shape — how many
35+
ranges, of what sizes, in how many dependent phases — which is what separates
36+
the two designs. Using a public object means the measurement needs no bucket of
37+
its own, and the client stack (s3fs, aiobotocore, HTTPS, real latency) is the
38+
one blosc2 would use.
39+
40+
Three modes are timed:
41+
42+
- ``chunk``: one range per touched chunk, ``max_concurrency`` at a time. What
43+
``lazy=True`` does today.
44+
- ``blocks``: one range per touched chunk for the header and block offsets,
45+
then one range per (coalesced) run of wanted blocks. Two dependent phases.
46+
- ``blocks, cached``: the same without the header phase, which is what a second
47+
slice of the same array costs once the offsets have been read once.
48+
"""
49+
50+
import argparse
51+
import itertools
52+
import math
53+
import random
54+
import statistics
55+
import time
56+
from concurrent.futures import ThreadPoolExecutor
57+
58+
import numpy as np
59+
60+
import blosc2
61+
62+
GAP = 4096 # merge ranges separated by less than this into one request
63+
64+
65+
def chunk_layout(schunk, nchunk, cache):
66+
"""(cbytes, bstarts, extents) of a chunk, as a byte-range reader would see it.
67+
68+
``bstarts`` is *not* sorted -- a multithreaded compressor writes blocks in
69+
completion order -- so a block's extent is the distance to the next larger
70+
offset, not to its neighbour in the array. The extents are computed that way
71+
here, rather than read from the lazy chunk's trailer, because that is all a
72+
byte-range reader over the network can do: it is an upper bound where a chunk
73+
has holes, which is what such a reader would fetch.
74+
"""
75+
if nchunk in cache:
76+
return cache[nchunk]
77+
# A lazy chunk is header + bstarts + trailer, so this reads a few hundred
78+
# bytes per chunk instead of the whole array
79+
chunk = schunk.get_lazychunk(nchunk)
80+
nbytes, cbytes, blocksize = blosc2.get_cbuffer_sizes(chunk)
81+
nblocks = (nbytes + blocksize - 1) // blocksize
82+
if (chunk[31] >> 4) & 0x7: # run-length chunk: no bytes in the file at all
83+
res = (0, np.empty(0, np.int64), np.empty(0, np.int64))
84+
else:
85+
if chunk[2] & 0x02: # memcpyed: raw blocks, no bstarts section
86+
bstarts = 32 + np.arange(nblocks, dtype=np.int64) * blocksize
87+
extents = np.full(nblocks, blocksize, dtype=np.int64)
88+
extents[-1] = nbytes - (nblocks - 1) * blocksize
89+
else:
90+
if len(chunk) < 32 + 4 * nblocks: # an in-memory array: no lazy chunks
91+
chunk = schunk.get_chunk(nchunk)
92+
bstarts = np.frombuffer(chunk[32 : 32 + 4 * nblocks], dtype="<i4").astype(np.int64)
93+
bounds = np.sort(np.append(bstarts, cbytes))
94+
extents = bounds[np.searchsorted(bounds, bstarts, "right")] - bstarts
95+
res = (cbytes, bstarts, extents)
96+
cache[nchunk] = res
97+
return res
98+
99+
100+
def coalesce(ranges):
101+
"""Sizes of the requests *ranges* becomes once near-adjacent ones are merged."""
102+
if not ranges:
103+
return []
104+
ranges = sorted(ranges)
105+
sizes, start, end = [], ranges[0][0], ranges[0][0] + ranges[0][1]
106+
for offset, size in ranges[1:]:
107+
if offset <= end + GAP:
108+
end = max(end, offset + size)
109+
else:
110+
sizes.append(end - start)
111+
start, end = offset, offset + size
112+
sizes.append(end - start)
113+
return sizes
114+
115+
116+
def touched(shape, chunks, blocks, item):
117+
"""{nchunk: [nblock]} for the slice *item*."""
118+
ndim = len(shape)
119+
item = tuple(item) + (slice(None),) * (ndim - len(item))
120+
spans = []
121+
for dim, index in enumerate(item):
122+
if isinstance(index, slice):
123+
start, stop, _ = index.indices(shape[dim])
124+
else:
125+
start = index if index >= 0 else index + shape[dim]
126+
stop = start + 1
127+
spans.append((start, stop))
128+
chunk_grid = [math.ceil(s / c) for s, c in zip(shape, chunks, strict=True)]
129+
blocks_in_chunk = [math.ceil(c / b) for c, b in zip(chunks, blocks, strict=True)]
130+
out = {}
131+
ranges = [range(s // chunks[d], (e - 1) // chunks[d] + 1) for d, (s, e) in enumerate(spans)]
132+
for coords in itertools.product(*ranges):
133+
nchunk = int(np.ravel_multi_index(coords, chunk_grid))
134+
per_dim = []
135+
for dim in range(ndim):
136+
lo = max(spans[dim][0] - coords[dim] * chunks[dim], 0)
137+
hi = min(spans[dim][1] - coords[dim] * chunks[dim], chunks[dim])
138+
per_dim.append(range(lo // blocks[dim], (hi - 1) // blocks[dim] + 1))
139+
out[nchunk] = [int(np.ravel_multi_index(b, blocks_in_chunk)) for b in itertools.product(*per_dim)]
140+
return out
141+
142+
143+
def request_plan(array, item):
144+
"""The requests each mode would issue for *item*: (chunk sizes, header sizes, block sizes)."""
145+
schunk, cache = array.schunk, {}
146+
chunk_sizes, header_sizes, block_sizes = [], [], []
147+
nblocks_touched = 0
148+
for nchunk, nblocks in touched(array.shape, array.chunks, array.blocks, item).items():
149+
cbytes, bstarts, extents = chunk_layout(schunk, nchunk, cache)
150+
if not cbytes: # special chunk: free in both modes
151+
continue
152+
chunk_sizes.append(int(cbytes))
153+
header_sizes.append(32 + 4 * len(bstarts))
154+
nblocks_touched += len(nblocks)
155+
block_sizes += coalesce([(int(bstarts[i]), int(extents[i])) for i in nblocks])
156+
return chunk_sizes, header_sizes, block_sizes, nblocks_touched
157+
158+
159+
def default_patterns(shape):
160+
"""Slices worth asking about, for an array of any shape."""
161+
mid = [s // 2 for s in shape]
162+
point = tuple(mid)
163+
line_last = (*mid[:-1], slice(None))
164+
line_first = (slice(None), *mid[1:])
165+
window = tuple(slice(m, m + max(1, s // 64)) for m, s in zip(mid, shape, strict=True))
166+
slab = (slice(mid[0], mid[0] + max(1, shape[0] // 100)), *[slice(None)] * (len(shape) - 1))
167+
big_slab = (slice(mid[0], mid[0] + max(1, shape[0] // 10)), *[slice(None)] * (len(shape) - 1))
168+
return [
169+
("point", point),
170+
("line, last dim", line_last),
171+
("line, first dim", line_first),
172+
("window (1/64 per dim)", window),
173+
("slab (1% of dim 0)", slab),
174+
("slab (10% of dim 0)", big_slab),
175+
]
176+
177+
178+
class Replayer:
179+
"""Issues the request pattern of a plan against a real object store."""
180+
181+
def __init__(self, urlpath, concurrency, anon=False, endpoint_url=None):
182+
import fsspec
183+
184+
options = {k: v for k, v in {"anon": anon, "endpoint_url": endpoint_url}.items() if v}
185+
if options:
186+
fsspec.config.conf.setdefault(urlpath.split("://", 1)[0], {}).update(options)
187+
self.fs, self.path = fsspec.url_to_fs(urlpath)
188+
self.size = self.fs.info(self.path)["size"]
189+
self.concurrency = concurrency
190+
self.random = random.Random(7)
191+
192+
def _one(self, size):
193+
# A fresh offset every time, so nothing is served from a cache anywhere
194+
size = min(size, self.size)
195+
offset = self.random.randrange(0, self.size - size + 1)
196+
return len(self.fs.cat_file(self.path, start=offset, end=offset + size))
197+
198+
def phase(self, sizes):
199+
"""One wave of parallel range reads, as Proxy.fetch issues them."""
200+
if not sizes:
201+
return
202+
with ThreadPoolExecutor(max_workers=min(self.concurrency, len(sizes))) as pool:
203+
list(pool.map(self._one, sizes))
204+
205+
def time(self, phases):
206+
t0 = time.perf_counter()
207+
for sizes in phases:
208+
self.phase(sizes)
209+
return time.perf_counter() - t0
210+
211+
212+
def main():
213+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
214+
p.add_argument("urlpath", help="a local .b2nd array to take the geometry from")
215+
p.add_argument("--replay", help="URL of any large object to replay the request pattern against")
216+
p.add_argument("--anon", action="store_true", help="anonymous access to the replay target")
217+
p.add_argument("--endpoint-url", help="for S3-compatible endpoints (R2, B2, MinIO...)")
218+
p.add_argument("--concurrency", type=int, default=8, help="parallel requests (default: 8)")
219+
p.add_argument("--reps", type=int, default=5, help="timed repetitions (default: 5)")
220+
p.add_argument("--max-mb", type=float, default=45, help="skip patterns fetching more than this")
221+
args = p.parse_args()
222+
223+
array = blosc2.open(args.urlpath)
224+
blocks_per_chunk = math.prod([math.ceil(c / b) for c, b in zip(array.chunks, array.blocks, strict=True)])
225+
print(
226+
f"{args.urlpath}: shape={array.shape} dtype={array.dtype} chunks={array.chunks} "
227+
f"blocks={array.blocks}\n {array.schunk.nchunks} chunks, {blocks_per_chunk} blocks/chunk, "
228+
f"cratio {array.schunk.cratio:.1f}x"
229+
)
230+
231+
plans = []
232+
print(
233+
f"\n {'pattern':22s} {'chunks':>6s} {'blocks':>13s} {'chunk mode':>18s} {'block mode':>18s} ratio"
234+
)
235+
for name, item in default_patterns(array.shape):
236+
chunk_sizes, header_sizes, block_sizes, nblocks = request_plan(array, item)
237+
chunk_bytes, block_bytes = sum(chunk_sizes), sum(header_sizes) + sum(block_sizes)
238+
ratio = block_bytes / chunk_bytes if chunk_bytes else float("nan")
239+
print(
240+
f" {name:22s} {len(chunk_sizes):6d} {nblocks:6d}/{len(chunk_sizes) * blocks_per_chunk:<6d} "
241+
f"{len(chunk_sizes):5d} req {chunk_bytes / 1e6:7.2f} MB "
242+
f"{len(header_sizes) + len(block_sizes):5d} req {block_bytes / 1e6:7.2f} MB {ratio * 100:6.1f}%"
243+
)
244+
plans.append((name, chunk_sizes, header_sizes, block_sizes, chunk_bytes, block_bytes))
245+
246+
if not args.replay:
247+
return
248+
249+
replayer = Replayer(args.replay, args.concurrency, args.anon, args.endpoint_url)
250+
print(
251+
f"\nreplaying against {args.replay} ({replayer.size / 1e6:.0f} MB), "
252+
f"concurrency {args.concurrency}, {args.reps} reps"
253+
)
254+
times = {name: {"chunk": [], "blocks": [], "cached": []} for name, *_ in plans}
255+
for rep in range(args.reps):
256+
for name, chunk_sizes, header_sizes, block_sizes, chunk_bytes, _ in plans:
257+
if chunk_bytes > args.max_mb * 1e6:
258+
continue
259+
times[name]["chunk"].append(replayer.time([chunk_sizes]))
260+
times[name]["blocks"].append(replayer.time([header_sizes, block_sizes]))
261+
times[name]["cached"].append(replayer.time([block_sizes]))
262+
print(f" rep {rep + 1}/{args.reps}", flush=True)
263+
264+
print(f"\n {'pattern':22s} {'chunk mode':>16s} {'blocks':>17s} {'blocks, cached':>17s}")
265+
for name, _sizes, _, _, chunk_bytes, block_bytes in plans:
266+
if not times[name]["chunk"]:
267+
print(f" {name:22s} skipped ({chunk_bytes / 1e6:.0f} MB > --max-mb)")
268+
continue
269+
median = {k: statistics.median(v) for k, v in times[name].items()}
270+
print(
271+
f" {name:22s} {chunk_bytes / 1e6:6.2f}MB {median['chunk']:5.2f}s "
272+
f"{block_bytes / 1e6:6.2f}MB {median['blocks']:5.2f}s {median['chunk'] / median['blocks']:4.1f}x "
273+
f"{median['cached']:11.2f}s {median['chunk'] / median['cached']:4.1f}x"
274+
)
275+
276+
277+
if __name__ == "__main__":
278+
main()

0 commit comments

Comments
 (0)