Skip to content

perf(merge_insert): stream missing partial-schema columns instead of collecting them in the join#7630

Open
ytyky wants to merge 1 commit into
lance-format:mainfrom
ytyky:fix-merge-insert-partial-schema-oom
Open

perf(merge_insert): stream missing partial-schema columns instead of collecting them in the join#7630
ytyky wants to merge 1 commit into
lance-format:mainfrom
ytyky:fix-merge-insert-partial-schema-oom

Conversation

@ytyky

@ytyky ytyky commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

A partial-schema merge_insert filled the dataset columns missing from the source by copying them out of the target side of the join (with_column(col("target.x"))). The target scan feeds the CollectLeft build side of the hash join, so every payload column of every target row was collected into memory before the source streamed through, and the plan executes with an unbounded memory pool. On wide tables (rows carrying large binary columns, datasets in the hundreds of GB) this reliably OOM-kills the process even when the merge only touches a small fraction of rows.

The join now carries only the join keys, _rowid/_rowaddr, the source columns, and the action column. The write exec fetches the missing columns per output batch with a take by row address (TargetColumnFiller): updated rows read the matched target row's values, inserted rows fill NULL. Peak memory is bounded by the batch size, and only the rows actually rewritten are read, instead of the full column for every target row.

Related to #1983 — fixes the partial-schema OOM; a configurable memory pool for the remaining paths is follow-up work.

@ytyky

ytyky commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Benchmark: peak memory before vs after

Memory experiment on a synthetic dataset shaped like the failing workload (wide binary payload column, partial-schema source). Setup:

  • dataset: 24,000 rows × 64 KiB binary payload (~1.5 GiB payload total), 4 fragments, local disk, v2 files, no scalar index on the key (v2 planned path)
  • merge: source provides only key, value (the payload column is missing from the source), when_matched=UpdateAll, when_not_matched=InsertAll (+100 inserted rows), join on key
  • measured: VmHWM (peak RSS) of a process that only opens the dataset and runs the merge (dataset creation runs in a separate process); debug build, 4-core container
update fraction peak RSS before (main @ f24e42c) peak RSS after (this PR) wall time before wall time after
20% of rows (4,800) 4,852 MiB 1,215 MiB (−75%) 12.6 s 7.2 s (1.8× faster)
5% of rows (1,200) 4,158 MiB 415 MiB (−90%) 8.6 s 1.9 s (4.5× faster)

The key structural difference: before, peak memory is ~3× the total payload size of the whole table regardless of how few rows are updated (the CollectLeft build side materializes the payload column for every target row, then join/write copies stack on top). After, peak memory tracks the number of matched rows (4× fewer updated rows → ~3× lower peak) and no longer grows with the payload width of the target table at all.

Extrapolated to the motivating workload (hundreds of GB of payload columns, updating 30–40% of rows via a partial-schema source): before the fix the build side alone must hold the table's full payload in RAM, which OOMs any reasonable machine; after the fix the merge streams.

@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.94649% with 48 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...lance/src/dataset/write/merge_insert/exec/write.rs 74.05% 30 Missing and 18 partials ⚠️

📢 Thoughts on this report? Let us know!

…collecting them in the join

A partial-schema merge_insert filled the dataset columns missing from
the source by copying them out of the target side of the join
(with_column(col("target.x"))). The target scan feeds the CollectLeft
build side of the hash join, so every payload column of every target
row was collected into memory before the source streamed through, and
the plan executes with an unbounded memory pool. On wide tables (rows
carrying large binary columns, datasets in the hundreds of GB) this
reliably OOM-kills the process even when the merge only touches a
small fraction of rows.

The join now carries only the join keys, _rowid/_rowaddr, the source
columns, and the action column. The write exec fetches the missing
columns per output batch with a take by row address
(TargetColumnFiller): updated rows read the matched target row's
values, inserted rows fill NULL. Peak memory is bounded by the batch
size, and only the rows actually rewritten are read, instead of the
full column for every target row.
@ytyky ytyky force-pushed the fix-merge-insert-partial-schema-oom branch from 264a161 to 1e49757 Compare July 5, 2026 07:17
@wjones127 wjones127 self-requested a review July 5, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant