perf(encoding): batch page reads of a structural column into one I/O request - #9379
Conversation
…request Nested columns in 2.1+ files are cut into many small pages by the rep/def budget of a mini-block chunk, and every page submitted its own read, so a take that touched many pages cost at least one request per page no matter how close the pages were in the file. The scheduling job now schedules pages until their reads add up to 8 MiB and submits them as one request sorted by file offset, which lets the I/O scheduler coalesce neighbouring pages. On a 2.1 copy of a production table a take of 2048 scattered rows over 8 fragments drops from 4,049 to 1,322 requests at the default object store block size, and a contiguous read of 2048 rows from 199 to 11.
4ce841e to
f4122f4
Compare
…h the budget A page that shards its own reads to bound buffering (blob pages) submitted every shard before the batch checked its budget, so the shards collapsed into one shared read of the whole selected payload. The batch now submits what is queued before accepting a read that would push it past the budget, and the job test covers a page that returns several load tasks.
There was a problem hiding this comment.
The page-sharding memory bound is now preserved: queued reads flush before an additional request can overflow the batch, and the multi-task page coverage exercises that boundary.
The reported increase in local-filesystem CPU at default I/O parallelism remains a non-blocking operating risk, although wall time was unchanged and object-store reads are unaffected. Bounding local I/O concurrency is the available mitigation.
westonpace
left a comment
There was a problem hiding this comment.
This is fine. It's more of a band-aid than a proper fix though. We really shouldn't be writing small pages in the first place. The problem is that we don't account for compression when we accumulate column data. #4371 would be a better fix but is much more involved. So we can save it for a future task.
|
@westonpace sounds good, thank you! |
Problem
In 2.1+ files a nested column is cut into pages by the rep/def budget of one mini-block chunk (
max_repdef_levels_per_chunk), not bymax_page_bytes. Alist<list<list<fixed_size_list<f16, 3>>>>column with ~3000 rows per fragment ends up as 77 pages of ~250 KB each.StructuralPrimitiveFieldSchedulingJob::schedule_nextscheduled one page per call and every page scheduler submitted its ownsubmit_request. The I/O scheduler only coalesces ranges inside one request, so a take that touches many pages costs at least one request per touched page no matter how close the pages sit in the file, and a scan costs one request per page. On this data a take of 2048 rows spread over 8 fragments never went below ~2,300 requests, whatever the coalescing gap.Change
schedule_nextschedules consecutive pages of the request through aPageReadBatchuntil their reads add up to 8 MiB (the writer's per-column accumulation size, so one batch is roughly what the writer meant to be one page), then submits them as one request sorted by file offset with the lowest page priority. The budget is enforced as reads are queued: a read that would push the batch past 8 MiB first submits what is queued, so a page that shards its own reads to bound buffering (blob pages) keeps every shard in a bounded request and never collapses into one read of the whole page. Each page's load future receives its own slice of the result through aSharedfuture. Reads submitted after the final flush, such as full-zip data behind a repetition index, pass straight through.Measurements
Local copy of 8 fragments (24,149 rows, six nested list columns, two of them three levels deep) of a production table.
Dataset::takeof 2048 rows (256 random rows per fragment), requests counted withio_stats_incremental.block_sizeis the scheduler's coalescing gap; 64 KiB is the object store default.Bytes read are the same for a given
block_size(62 MiB → 62 MiB at 4 KiB, 100 MiB → 103 MiB at 64 KiB); only the request count changes.Tests
page_read_batch_submits_pending_reads_as_one_request: ordering by offset, lowest priority, per-page slicing of the result, pass-through after the flush, and the early submit of a full batch when the next read would overflow it.schedule_next_batches_page_reads_up_to_the_budget: pages of one job are read together, untouched pages are not read, the byte budget splits batches, and a page that returns severalPageLoadTasks (two 8 MiB shards) gets one bounded request per shard.test_read_batches_page_reads_into_one_request(lance-file): a five-page column is read with one data request.initialize_builds_only_requested_page_schedulersupdated: the two initialized pages now come back from a singleschedule_nextcall.CPU on a local filesystem
On a local filesystem the change makes the I/O burstier: a batch of pages is requested at once instead of page by page. The local reader runs every
preadthroughspawn_blocking, and on a 128-core machine the wake-ups of tokio's blocking pool contend on one futex, so at the default localio_parallelismthe same take spends more CPU while wall time is unchanged (10 takes: task-clock 2.6 s → 6.0 s, wall 0.10 s → 0.10 s,perfshows the extra time innative_queued_spin_lock_slowpathunderfutex_wait/futex_wakeof the blocking pool). With the I/O concurrency bounded the batched reader is cheaper than before (LANCE_IO_THREADS=4: 2.22 s → 2.06 s;=2: 1.80 s → 1.40 s). Object stores do not go throughspawn_blocking. The blocking-pool cost is a property of the local reader and worth a change of its own.