Skip to content

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

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

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

@wjones127 wjones127 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.

I appreciate you taking an interest in this work. However, this isn't aligned with the direction we are going for.

One fix we plan to do immediately:

  1. Get statistics from the input data, so we can make the input data the build side instead of the target table, when the input data is smaller. #7368
  2. Support filter pushdown from hash joins: #7562
  3. Consider using TakeExec to defer read of large columns using an optimizer rule: #7363

That third one is closer to what you are trying to do here, except it does it via an optimizer rule so it can be shared with more query type. In general, the direction we are trying to go for is write less bespoke merge-insert code, and write more general query optimization primitives.

The problem I found with deferring the take is that it's not always beneficial, particularly for narrow columns. So more work is needed to design a better optimizer rule.

On your benchmark, one thing that isn't clear to me is how much of the same benefit could be gotten by applying PR #7368 to flip the join order. Perhaps worth trying that same situation out on that PR branch to compare to this solution. Perhaps it can do just as well.

@ytyky

ytyky commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for pointing out. Closing in favor of the optimizer-primitive direction — #7363 covers the failure mode this
PR targeted

@ytyky ytyky closed this Jul 7, 2026
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.

2 participants