Skip to content

perf(encoding): batch page reads of a structural column into one I/O request - #9379

Merged
westonpace merged 2 commits into
lance-format:mainfrom
dshepelev15:perf/encoding-batch-page-io
Sep 18, 2026
Merged

westonpace merged 2 commits into
lance-format:mainfrom
dshepelev15:perf/encoding-batch-page-io

Conversation

@dshepelev15

@dshepelev15 dshepelev15 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

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 by max_page_bytes. A list<list<list<fixed_size_list<f16, 3>>>> column with ~3000 rows per fragment ends up as 77 pages of ~250 KB each.

StructuralPrimitiveFieldSchedulingJob::schedule_next scheduled one page per call and every page scheduler submitted its own submit_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_next schedules consecutive pages of the request through a PageReadBatch until 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 a Shared future. 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::take of 2048 rows (256 random rows per fragment), requests counted with io_stats_incremental. block_size is the scheduler's coalescing gap; 64 KiB is the object store default.

file version rows block_size requests before requests after
2.1 2048 scattered 4 KiB 5,852 3,246
2.1 2048 scattered 64 KiB 4,049 1,322
2.1 2048 scattered 256 KiB 3,280 484
2.2 2048 scattered 64 KiB 4,012 1,293
2.1 2048 contiguous 4 KiB 199 11
2.0 (path unchanged) 2048 scattered 64 KiB 1,360 1,352

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 several PageLoadTasks (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_schedulers updated: the two initialized pages now come back from a single schedule_next call.

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 pread through spawn_blocking, and on a 128-core machine the wake-ups of tokio's blocking pool contend on one futex, so at the default local io_parallelism the 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, perf shows the extra time in native_queued_spin_lock_slowpath under futex_wait/futex_wake of 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 through spawn_blocking. The blocking-pool cost is a property of the local reader and worth a change of its own.

@github-actions github-actions Bot added A-encoding Encoding, IO, file reader/writer performance labels Sep 18, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

…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.
@dshepelev15
dshepelev15 force-pushed the perf/encoding-batch-page-io branch from 4ce841e to f4122f4 Compare September 18, 2026 11:13
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-changes Latest Gatekeeper recommendation requests changes. and removed K-changes Latest Gatekeeper recommendation requests changes. labels Sep 18, 2026
…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.
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 18, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Gate recommendation: approve with a non-blocking risk.

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.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 18, 2026

@westonpace westonpace left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
westonpace merged commit 5b98a9a into lance-format:main Sep 18, 2026
39 of 42 checks passed
@dshepelev15

dshepelev15 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

@westonpace sounds good, thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-encoding Encoding, IO, file reader/writer K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants