[python] Reuse streams for coalesced blob reads - #9133
Conversation
|
Could we avoid introducing A cleaner design would be for Jindo’s
This follows the capability-based design used by POSIX One important detail is that the range-read path must not go through I also noticed two correctness issues:
|
leaves12138
left a comment
There was a problem hiding this comment.
Reviewed the latest lane-based implementation. Exclusive lane ownership, bounded per-path concurrency, stream switching, fallback cleanup, and error propagation look correct. Targeted tests, randomized multi-path stress, and repeated real OSS reads all passed without data mismatches or crashes.
|
I reviewed the final lane-based diff specifically for scope and unnecessary changes. The lane ownership model itself looks reasonable, but I think the PR can be narrowed further. Changes that can be removed directly
Changes that should preferably be split into backend-specific PRs
Moving the HDFS/Jindo work out would remove about 210 touched lines from the core PR and leave it focused on lane scheduling and stream lifecycle. Needs justification
I would keep the path-aware lane assignment, exclusive cursor access for unknown-length ranges, closing a failed lane before fallback, global stream bounding across paths, and the close-error/read-error handling. Those are meaningful correctness and resource-lifecycle parts of the final design. |
JingsongLi
left a comment
There was a problem hiding this comment.
Follow-up review of the narrowed lane-based implementation.
| _COALESCE_GAP = 1 << 20 | ||
| _COALESCE_SPAN = 8 << 20 | ||
| _COALESCE_VIEW_MAX_RETAINED_AMPLIFICATION = 2.0 | ||
| _MAX_RANGE_LANES_PER_PATH = 16 |
There was a problem hiding this comment.
Could we document and justify this fixed value? parallelism already bounds global concurrency, while this additionally changes single-path behavior: for example, Daft's default max_concurrency=64 is silently reduced to 16 for one object. The current benchmark uses parallelism 8, so it does not establish why 16 is the right resource/performance trade-off.
There was a problem hiding this comment.
Could we document and justify this fixed value?
parallelismalready bounds global concurrency, while this additionally changes single-path behavior: for example, Daft's defaultmax_concurrency=64is silently reduced to 16 for one object. The current benchmark uses parallelism 8, so it does not establish why 16 is the right resource/performance trade-off.
Got your point. If there are 64 tasks with parallelism=64 and no per-path cap, the number of stream opens is not reduced, so it cannot solve the QPS issue. That is my concern. So I try to add a max values here, In our same-Blob benchmark, it reduced stream opens by 75% compared with 64, with only about 5% end-to-end latency overhead. Lower values increased latency more noticeably, while higher values saved less open/HEAD QPS, so 16 was chosen as the balance point. Will update PR doc with more testing benchmark.
| ) | ||
| path_lanes = min( | ||
| len(path_tasks), proportional_lanes, | ||
| _MAX_RANGE_LANES_PER_PATH) |
There was a problem hiding this comment.
Could we redistribute lanes after applying the per-path cap? proportional_lanes is calculated against the uncapped task distribution, so a hot path permanently discards the lanes removed by the cap. With parallelism=64 and path task counts [9900, 100], this assigns only 16 + 1 = 17 non-empty lanes even though 32 are available under the cap; [1000, 100, 100, 100] uses only 31 even though all 64 could be used. A capped/water-fill allocation (or another rebalancing pass) would avoid this substantial underutilization.
| opened() | ||
|
|
||
| def read_at(self, length, offset): | ||
| barrier.wait() |
There was a problem hiding this comment.
Please give this barrier a timeout and explicitly assert that it was not broken. If a scheduling regression creates fewer than eight concurrent lanes, the current test will hang CI indefinitely instead of failing with a useful diagnostic.
| ranges, parallelism=64, max_gap=0), | ||
| ) | ||
| self.assertGreater(len(streams), 1) | ||
| self.assertLessEqual(len(streams), 16) |
There was a problem hiding this comment.
Could this test assert the expected allocation more precisely? 1 < len(streams) <= 16 would still pass if a regression allocated only two lanes. For 64 tasks with parallelism 64, the current policy should produce exactly 16 streams, with four reads per stream. It would also be valuable to add a skewed multi-path case that catches lanes lost after applying the cap.
| path_lane_counts = [1] * len(path_task_groups) | ||
| remaining_lanes = max( | ||
| 0, | ||
| min(workers, sum(path_capacities)) - len(path_task_groups), |
There was a problem hiding this comment.
[P2] Do not cap reusable path memberships at the worker count
remaining_lanes treats the sum of per-path lane memberships as an exclusive global budget. However, a physical worker lane can serve multiple paths sequentially, so these memberships do not need to sum to at most workers. For example, with parallelism=64, one path containing 10,000 spans, and 63 paths containing one span each, len(path_task_groups) == workers makes remaining_lanes zero and assigns only one lane to the hot path. The other 63 workers become idle after their singleton reads while the hot path stays serial, making the tail close to 16x slower than the per-path cap permits; the previous proportional allocator did give this path 16 lanes. Please preserve capped proportional memberships and only water-fill an under-allocation without shrinking totals above workers, or use dynamic scheduling. A hot-path-plus-63-singletons test should assert 16 hot-path lanes while global active streams remain at most 64.
| selected = sorted( | ||
| range(workers), key=lane_loads.__getitem__)[:path_lanes] | ||
| for index, task in enumerate(path_tasks): | ||
| lane = selected[index % path_lanes] |
There was a problem hiding this comment.
[P2] Balance static lanes by estimated I/O cost
The allocation ratios, lane_loads, and this round-robin placement all treat every task as equal, although a merged span can range from a few bytes to 8 MiB and these static lanes cannot steal work. For one path using 16 lanes, 256 non-coalescing spans with an 8 MiB span at indices 0, 16, ..., 240 send all sixteen large reads to lane 0 via index % path_lanes: that lane transfers 128 MiB while the other 15 lanes process only tiny reads. The previous per-task executor did not pin this periodic size pattern to one worker. Please assign an estimated weight to each task, such as span_len plus a fixed request cost, and place each task on the least weighted-load selected lane. A heterogeneous-span regression test should verify that the large reads are distributed across lanes.
| else stream.read(length)) | ||
| except Exception: | ||
| self._close_current() | ||
| return self._file_io.read_file_range( |
There was a problem hiding this comment.
[P2] Stop before fallback when closing the failed handle also fails
The exception path calls _close_current(), which clears the stream reference and records a close error, and then opens a fresh fallback handle here. A failed close() does not guarantee that the underlying descriptor or connection was released. With parallelism=1, a single read failure followed by a close failure can therefore leave the original handle active while read_file_range opens a second one, violating the global stream bound and losing the only reference to the leaked handle. The recorded close error also makes the batch fail at the end, so this fallback I/O cannot salvage the result. Please abort the lane before opening another handle when close fails, preserving the read error as primary when applicable, and update test_failed_stream_close_error_is_propagated_after_fallback to assert that no fallback is attempted.
Purpose
Coalesced BLOB range reads currently open and close an input stream for every merged span. A batch containing several spans from the same physical file therefore repeats stream-open overhead.
Changes
FileIO.new_input_streamAPI.Backend-specific HDFS and Jindo stream implementations are unchanged. The direct
FormatBlobReaderpath is also unchanged.Tests
Read-only benchmark
For 64 sparse ranges totaling 15.5 MiB from one OSS object, with global
parallelism=64:All results were byte-identical. The cap of 16 reduces same-object stream-open/HEAD requests by 75% while adding about 0.32s to payload reading. Planning and descriptor reading took about 6.7s in this workload, so the end-to-end overhead was about 5%. The cap is per path; capped water-fill allocation allows multi-path workloads to continue using the requested global parallelism.