Skip to content

feat(datafusion): estimate total_byte_size for Lance scans - #9430

Open
vivek-bharathan wants to merge 2 commits into
lance-format:mainfrom
vivek-bharathan:vb/scan-byte-size
Open

vivek-bharathan wants to merge 2 commits into
lance-format:mainfrom
vivek-bharathan:vb/scan-byte-size

Conversation

@vivek-bharathan

Copy link
Copy Markdown
Contributor

Lance scans reported no byte size, so DataFusion's join guards fell back to row counts and broadcast wide tables. Scan, filtered read and take now report rows times a per-row width: the decoder's estimate of the values plus the Arrow buffers around them, floored at 8 bytes. That floor reproduces the 128 Ki row cap at DataFusion's default threshold ratio.

@github-actions github-actions Bot added A-python Python bindings enhancement New feature or request labels Sep 19, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 19, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 21, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 21, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 21, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 21, 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.

I have one blocking question. Otherwise, just some nits about comments. LLMs seem to generate very long and difficult to read comments and it would be nice if we could prune them or make them more concise.

Comment thread rust/lance/src/io/exec/filtered_read.rs Outdated
Comment thread rust/lance/src/io/exec/filtered_read.rs Outdated
Comment thread rust/lance/src/io/exec/filtered_read.rs Outdated
Comment thread rust/lance/src/io/exec/filtered_read.rs Outdated
Comment thread rust/lance/src/io/exec/filtered_read.rs
return None;
}
Some(bytes_per_row.max(MIN_BYTES_PER_ROW))
}

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.

Blocking: I worry this is too brittle. Variable length fields (string, binary) will report None for byte_width_opt and if I'm reading this correctly that will cause us to report None for the entire schema? We will then return Precision::Absent for estimated_total_byte_size.

Would it be better to at least make some kind of guess in this case?

@vivek-bharathan vivek-bharathan Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have another PR in the works to address this issue by measuring the actual data. But now that I think about it, maybe we could start with some default estimate that then gets updated from the cache with the actual measured value. wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed: a variable-width column now gets a default width instead of withdrawing the whole row, so total_byte_size is no longer Absent for any schema containing a string. #9477 measures real Arrow bytes from batches a scan has already decoded, caches them per dataset version, and uses them from the next query on. So the first query in a session uses the default and every one after it uses a measurement

@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 22, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 22, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 23, 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: request changes.

Schema-seeded variable widths reintroduce the merge-insert memory regression. Please keep total_byte_size absent for unmeasured variable-width fields while retaining fixed-width estimates, or derive comparable widths from decoded data before exposing them to DataFusion. Draft #9477 explores measured widths but is stacked on the prior head, so this revision needs safe standalone behavior.

other => match other.byte_width_opt().or_else(|| other.primitive_width()) {
Some(width) => validity + width as f64,
// `arrow_overhead_bytes_per_row` carries the validity term for these.
None => seeded_value_bytes_per_row(other) + arrow_overhead_bytes_per_row(field),

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.

This reintroduces the earlier finding. The 64-byte string seed plus offsets makes a 1,000-row target look smaller than an exact 100-row source, although the target's projected columns occupy 1,056,824 bytes versus 132,136 bytes for the source. DataFusion then builds the hash table from LanceRead. Wider document-like values can make the nominally small target build consume target-sized memory and fail again on retry. Keep total_byte_size absent for unmeasured variable-width fields, or use measured decoded widths; the decoder's batch-sizing seed is not a safe join-size comparison.

Reproducer

Add this test under merge_insert.rs::tests and run cargo test -p lance --lib gate_repro_variable_width_join_orientation -- --nocapture.

#[tokio::test]
async fn gate_repro_variable_width_join_orientation() {
    use arrow_array::{Array, RecordBatchIterator, StringArray, UInt32Array};
    use arrow_schema::{DataType, Field, Schema};

    fn find_hash_join(plan: &dyn ExecutionPlan) -> Option<&HashJoinExec> {
        if let Some(join) = plan.downcast_ref::<HashJoinExec>() {
            return Some(join);
        }
        plan.children()
            .into_iter()
            .find_map(|child| find_hash_join(child.as_ref()))
    }

    let wide = "x".repeat(1_024);
    let target_schema = Arc::new(Schema::new(vec![
        Field::new("key", DataType::UInt32, false),
        Field::new("value", DataType::Utf8, false),
        Field::new("other", DataType::Utf8, true),
    ]));
    let target = RecordBatch::try_new(
        target_schema.clone(),
        vec![
            Arc::new(UInt32Array::from((0..1_000).collect::<Vec<_>>())),
            Arc::new(StringArray::from_iter_values((0..1_000).map(|_| "old"))),
            Arc::new(StringArray::from_iter_values((0..1_000).map(|_| wide.as_str()))),
        ],
    )
    .unwrap();
    let target_bytes =
        target.column(0).get_array_memory_size() + target.column(2).get_array_memory_size();
    let ds = Arc::new(
        Dataset::write(
            RecordBatchIterator::new([Ok(target)], target_schema),
            "memory://",
            None,
        )
        .await
        .unwrap(),
    );

    let source_schema = Arc::new(Schema::new(vec![
        Field::new("key", DataType::UInt32, false),
        Field::new("value", DataType::Utf8, false),
    ]));
    let source = RecordBatch::try_new(
        source_schema.clone(),
        vec![
            Arc::new(UInt32Array::from((0..100).collect::<Vec<_>>())),
            Arc::new(StringArray::from_iter_values((0..100).map(|_| wide.as_str()))),
        ],
    )
    .unwrap();
    let source_bytes: usize = source
        .columns()
        .iter()
        .map(|column| column.get_array_memory_size())
        .sum();
    assert!(target_bytes > source_bytes * 4);

    let provider: Arc<dyn TableProvider> = Arc::new(
        datafusion::datasource::MemTable::try_new(source_schema, vec![vec![source]]).unwrap(),
    );
    let plan = crate::dataset::MergeInsertBuilder::try_new(ds, vec!["key".to_string()])
        .unwrap()
        .when_matched(crate::dataset::WhenMatched::UpdateAll)
        .when_not_matched(crate::dataset::WhenNotMatched::DoNothing)
        .try_build()
        .unwrap()
        .create_plan(provider)
        .await
        .unwrap();
    let join = find_hash_join(plan.as_ref()).expect("a materialized source plans a hash join");
    let build = format!(
        "{}",
        datafusion::physical_plan::displayable(join.left().as_ref()).indent(true)
    );
    assert!(
        build.contains("DataSourceExec") && !build.contains("LanceRead"),
        "source={source_bytes}, target={target_bytes}, build={build}"
    );
}

Expected: the smaller DataSourceExec builds. Observed on 3b5f3a46756e2b1ddc07f8dadb99078859e979f6: the assertion fails with source=132136, target=1056824, build=LanceRead: ... projection=[key, other].

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 23, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 24, 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: request changes.

The variable-width merge-insert regression still reproduces after this rebase. The author confirms that a cold session uses the schema seed; #9477 is now ready but remains stacked on this head and still seeds cold scans, so it does not remove the first-query failure. Please retain fixed-width estimates while leaving total_byte_size absent for unmeasured variable-width output, or derive a comparable data-aware width before publishing it to DataFusion.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 24, 2026
Lance scans reported no byte size, so DataFusion's join guards fell back to row
counts and broadcast wide tables. Scan, filtered read and take now report rows
times a per-row width, over the columns a schema can actually measure: the
fixed-width primitives, booleans at a bit a row, nulls at nothing, and structs
or fixed-size lists of those. The width is floored at 8 bytes, which reproduces
the 128 Ki row cap at DataFusion's default threshold ratio.
…timate

A schema fixes no per-row width for strings, binary, lists, maps or
dictionaries, so scans reported no byte size at all for any row holding one
and DataFusion fell back to row counts.

Seed those columns from lance_encoding's estimate_bytes_per_row plus the Arrow
buffers around them: 64 bytes and a 4-byte offset for a string, five items for
a list. A width is now always reported.

Blob payloads stay excluded, and the guard is decided from what a node emits
rather than from a projection's blob mode: the public output schema drops the
blob marker, and a take or row-stream read carries columns it never projected.
It covers v1 blobs as well as v2, and recurses through lists and maps.

Dictionaries cost their keys at every nesting depth rather than the values they
decode to.

The four merge_insert plan snapshots move from CollectLeft to Partitioned.
That is the correct plan rather than a regression: the 32,768-row uuid-hex key
target is roughly 1.6 MiB, over DataFusion's 1 MiB collect threshold, and it
was broadcast before only because no byte size existed and the row count fell
under the 128 Ki cap.

This branch has not been deployed

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

Labels

A-python Python bindings enhancement New feature or request K-changes Latest Gatekeeper recommendation requests changes.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants