sunchao commented on code in PR #26214:
URL: https://github.com/apache/datafusion/pull/26214#discussion_r4429691135


##########
datafusion/physical-plan/src/joins/hash_join/not_in.rs:
##########
@@ -1136,24 +1234,50 @@ where
             build_lookups: 0,
             #[cfg(test)]
             mark_checks: 0,
+            #[cfg(test)]
+            max_decided_rows: 0,
             f,
         }
     }
 
     /// Pairs each of `probe_rows` with each of `build_rows`. The pairing stops
     /// when every row that the pairs decide is marked.
+    ///
+    /// When the pairs decide the build rows, the build rows are paired in
+    /// chunks of at most `batch_size` rows. The rows held at once then stay
+    /// bounded by the batch size rather than by the build side, which matters
+    /// when this pairing stands in for a scope map that the memory pool
+    /// declined.
     fn cross_product(
         &mut self,
         build_rows: impl Iterator<Item = u64>,
         probe_rows: impl Iterator<Item = u32>,
     ) -> Result<()> {
-        // Drop the rows that are already marked before the first pair.
-        self.needs_refresh = true;
         match self.marks {
             UnknownMarks::Build(_) => {
-                self.pair_unmarked(&mut build_rows.collect(), probe_rows, |b, 
p| (b, p))
+                let probe_rows: Vec<u32> = probe_rows.collect();
+                if probe_rows.is_empty() {
+                    return Ok(());
+                }
+                let mut build_rows = build_rows.peekable();
+                let mut chunk = Vec::with_capacity(self.batch_size);
+                while build_rows.peek().is_some() {
+                    chunk.clear();
+                    chunk.extend(build_rows.by_ref().take(self.batch_size));

Review Comment:
   **[P2] Fill chunks with unmarked build rows.** This chunks the original 
indices before removing rows already marked. If one live row survives in each 
chunk, each survivor can be paired with an entire probe batch before its first 
passing match is recorded.
   
   I reproduced this in a `LeftMark` query with a residual predicate that 
always returns true and counts the rows evaluated. Of 1,048,576 build rows, the 
first probe batch marked all but one per 8,192. For the next 8,192-row probe 
batch, the merge base evaluated 8,192 candidate pairs in one predicate call. 
This head evaluated 1,048,576 pairs in 128 calls. The physical plan and SQL 
result matched.
   
   In a pairing control, compacting the unmarked indices across input-chunk 
boundaries before pairing restored the single 8,192-pair call. The largest 
individual temporary allocation stayed at 64 KiB.



##########
datafusion/physical-plan/src/joins/hash_join/not_in.rs:
##########
@@ -1136,24 +1234,50 @@ where
             build_lookups: 0,
             #[cfg(test)]
             mark_checks: 0,
+            #[cfg(test)]
+            max_decided_rows: 0,
             f,
         }
     }
 
     /// Pairs each of `probe_rows` with each of `build_rows`. The pairing stops
     /// when every row that the pairs decide is marked.
+    ///
+    /// When the pairs decide the build rows, the build rows are paired in
+    /// chunks of at most `batch_size` rows. The rows held at once then stay
+    /// bounded by the batch size rather than by the build side, which matters
+    /// when this pairing stands in for a scope map that the memory pool
+    /// declined.
     fn cross_product(
         &mut self,
         build_rows: impl Iterator<Item = u64>,
         probe_rows: impl Iterator<Item = u32>,
     ) -> Result<()> {
-        // Drop the rows that are already marked before the first pair.
-        self.needs_refresh = true;
         match self.marks {
             UnknownMarks::Build(_) => {
-                self.pair_unmarked(&mut build_rows.collect(), probe_rows, |b, 
p| (b, p))
+                let probe_rows: Vec<u32> = probe_rows.collect();

Review Comment:
   **[P2] Avoid materializing every probe index before checking the build 
marks.** A `StreamingTable` can hand the join a record batch larger than the 
configured batch size. I ran a single-partition `LeftMark` with one build row 
`(NULL, 1)`, an 8,192-row target, and a 256 KiB memory pool. The first probe 
batch `[(1, 1)]` marks the only build row. The next batch contains 8,388,608 
copies of `(1, 1)`.
   
   An allocator trace captured a 32 MiB `Vec<u32>` here on this head. The same 
SQL and source on the merge base produced the same NULL result without that 
allocation. No candidate pair from the second batch is needed. Could the caller 
supply a replayable probe iterator, recreated from the range or null bitmap for 
each surviving build chunk? That would bound this extra memory for streaming 
inputs too.



##########
datafusion/optimizer/src/decorrelate_predicate_subquery.rs:
##########
@@ -631,6 +632,34 @@ impl InValue {
             scope_filters,
         ))
     }
+
+    /// Can either side hold a NULL that its schema does not show? See
+    /// [`type_may_hide_null`].
+    fn may_hide_null(
+        &self,
+        left_schema: &DFSchema,
+        right_schema: &DFSchema,
+    ) -> Result<bool> {
+        Ok(type_may_hide_null(&self.value.get_type(left_schema)?)
+            || 
type_may_hide_null(&self.subquery_column.get_type(right_schema)?))
+    }
+}
+
+/// Can an array of this type hold a NULL in a field declared non-nullable?
+///
+/// An array of these types can be logically NULL at a slot without a null
+/// bit: a dictionary key that points to a NULL value, a run of NULLs, or a
+/// NULL in the selected union child. The null count covers only the null
+/// bits, so a field inferred from such an array, as
+/// `RecordBatch::try_from_iter` does, is non-nullable although the array
+/// holds NULLs.
+fn type_may_hide_null(data_type: &DataType) -> bool {
+    matches!(
+        data_type,
+        DataType::Dictionary(_, _)

Review Comment:
   **[P1] Keep `DataType::Null` among the value keys too.** A nonempty Arrow 
`NullArray` has a physical null count of zero even though every value is 
logically NULL. `RecordBatch::try_from_iter` therefore infers a nonnullable 
field, and DataFusion accepts `Null` as a hash key.
   
   I used the same fixture shape as the new dictionary test, with outer `a = 
[1, NULL], b = NullArray(2)` and inner `x = [1], y = NullArray(1)`. On this 
head, `(a,b) NOT IN (SELECT x,y FROM i)` returns `TRUE` for both rows, so both 
pass the `WHERE` filter. The merge base and the three-valued SQL reference 
return `NULL` for both and the filter returns no rows. On this same head, 
changing the subquery to `SELECT x, NULL FROM i` preserves the inner values, 
keeps both value keys, and restores the expected result. Could the type guard 
and regression test cover `DataType::Null`?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to