Skip to content

Commit 399d2e0

Browse files
adriangbclaude
andcommitted
feat(hash-join): measure the work that a dynamic filter saves for each removed row
The hash join records, in the `RemovedRowWork` of each dynamic filter that it produces, the rows of each probe batch and the time of the work that it does for every probe row, match or no match: the evaluation and the hashes of the join keys and the hash table lookup. A row that the filter removes before the join does not get this work. The work for a matched row (the output) is not in it: the filter does not remove matched rows. PR: #25681 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d8a3e29 commit 399d2e0

2 files changed

Lines changed: 162 additions & 21 deletions

File tree

‎datafusion/physical-plan/src/joins/hash_join/exec.rs‎

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1836,6 +1836,21 @@ impl ExecutionPlan for HashJoinExec {
18361836

18371837
let batch_size = context.session_config().batch_size();
18381838

1839+
// The join measures the work that its dynamic filters save for each
1840+
// row that they remove (see `RemovedRowWork`).
1841+
let removed_row_work = self
1842+
.dynamic_filter
1843+
.as_ref()
1844+
.filter(|_| enable_dynamic_filter_pushdown)
1845+
.map(|df| {
1846+
df.membership
1847+
.iter()
1848+
.chain(df.bounds.iter())
1849+
.map(|filter| Arc::clone(filter.removed_row_work()))
1850+
.collect()
1851+
})
1852+
.unwrap_or_default();
1853+
18391854
// we have the batches and the hash map with their keys. We can how create a stream
18401855
// over the right that uses this information to issue new batches.
18411856
let right_stream = self.right.execute(partition, context)?;
@@ -1855,27 +1870,30 @@ impl ExecutionPlan for HashJoinExec {
18551870
.map(|(_, right_expr)| Arc::clone(right_expr))
18561871
.collect::<Vec<_>>();
18571872

1858-
Ok(Box::pin(HashJoinStream::new(
1859-
partition,
1860-
self.schema(),
1861-
on_right,
1862-
self.filter.clone(),
1863-
self.join_type,
1864-
right_stream,
1865-
self.random_state.random_state().clone(),
1866-
join_metrics,
1867-
column_indices_after_projection,
1868-
self.null_equality,
1869-
HashJoinStreamState::WaitBuildSide,
1870-
BuildSide::Initial(BuildSideInitialState { left_fut }),
1871-
batch_size,
1872-
vec![],
1873-
self.right.output_ordering().is_some(),
1874-
build_accumulator,
1875-
self.mode,
1876-
null_aware,
1877-
self.fetch,
1878-
)))
1873+
Ok(Box::pin(
1874+
HashJoinStream::new(
1875+
partition,
1876+
self.schema(),
1877+
on_right,
1878+
self.filter.clone(),
1879+
self.join_type,
1880+
right_stream,
1881+
self.random_state.random_state().clone(),
1882+
join_metrics,
1883+
column_indices_after_projection,
1884+
self.null_equality,
1885+
HashJoinStreamState::WaitBuildSide,
1886+
BuildSide::Initial(BuildSideInitialState { left_fut }),
1887+
batch_size,
1888+
vec![],
1889+
self.right.output_ordering().is_some(),
1890+
build_accumulator,
1891+
self.mode,
1892+
null_aware,
1893+
self.fetch,
1894+
)
1895+
.with_removed_row_work(removed_row_work),
1896+
))
18791897
}
18801898

18811899
fn metrics(&self) -> Option<MetricsSet> {
@@ -8750,6 +8768,87 @@ mod tests {
87508768
Ok(())
87518769
}
87528770

8771+
/// The join measures the work that its dynamic filter saves for each
8772+
/// row that the filter removes: its work for each probe row.
8773+
#[tokio::test]
8774+
async fn test_dynamic_filter_measures_removed_row_work() -> Result<()> {
8775+
let task_ctx = Arc::new(TaskContext::default());
8776+
let left = build_table(
8777+
("a1", &vec![1, 2, 3]),
8778+
("b1", &vec![4, 5, 6]),
8779+
("c1", &vec![7, 8, 9]),
8780+
);
8781+
// `MIN_OBSERVED_ROWS` probe rows.
8782+
let rows = datafusion_physical_expr::filter_stats::MIN_OBSERVED_ROWS as i32;
8783+
let values: Vec<i32> = (0..rows).collect();
8784+
let right = build_table(("a2", &values), ("b2", &values), ("c2", &values));
8785+
let on = vec![(
8786+
Arc::new(Column::new_with_schema("a1", &left.schema())?) as _,
8787+
Arc::new(Column::new_with_schema("a2", &right.schema())?) as _,
8788+
)];
8789+
let dynamic_filter = HashJoinExec::create_dynamic_filter(&on);
8790+
let consumer: Arc<dyn PhysicalExpr> = Arc::clone(&dynamic_filter) as _;
8791+
// The consumer does not apply the filter here: the join sees all
8792+
// probe rows.
8793+
let right = Arc::new(FilterExecBuilder::new(consumer, right).build()?);
8794+
let mut join = HashJoinExec::try_new(
8795+
left,
8796+
right,
8797+
on,
8798+
None,
8799+
&JoinType::Inner,
8800+
None,
8801+
PartitionMode::CollectLeft,
8802+
NullEquality::NullEqualsNothing,
8803+
false,
8804+
)?;
8805+
join.dynamic_filter = Some(HashJoinExecDynamicFilter::new(
8806+
Some(Arc::clone(&dynamic_filter)),
8807+
None,
8808+
));
8809+
let work = Arc::clone(dynamic_filter.removed_row_work());
8810+
assert_eq!(work.ns_per_row(), None);
8811+
let batches = common::collect(join.execute(0, task_ctx)?).await?;
8812+
assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 3);
8813+
// The filter removed all but 3 probe rows, thus the join saw fewer
8814+
// than `MIN_OBSERVED_ROWS` rows: no measurement yet.
8815+
assert_eq!(work.ns_per_row(), None);
8816+
8817+
// Without a consumer that filters, the join sees all probe rows.
8818+
let task_ctx = Arc::new(TaskContext::default());
8819+
let left = build_table(
8820+
("a1", &vec![1, 2, 3]),
8821+
("b1", &vec![4, 5, 6]),
8822+
("c1", &vec![7, 8, 9]),
8823+
);
8824+
let right = build_table(("a2", &values), ("b2", &values), ("c2", &values));
8825+
let on = vec![(
8826+
Arc::new(Column::new_with_schema("a1", &left.schema())?) as _,
8827+
Arc::new(Column::new_with_schema("a2", &right.schema())?) as _,
8828+
)];
8829+
let dynamic_filter = HashJoinExec::create_dynamic_filter(&on);
8830+
let mut join = HashJoinExec::try_new(
8831+
left,
8832+
right,
8833+
on,
8834+
None,
8835+
&JoinType::Inner,
8836+
None,
8837+
PartitionMode::CollectLeft,
8838+
NullEquality::NullEqualsNothing,
8839+
false,
8840+
)?;
8841+
join.dynamic_filter = Some(HashJoinExecDynamicFilter::new(
8842+
Some(Arc::clone(&dynamic_filter)),
8843+
None,
8844+
));
8845+
let batches = common::collect(join.execute(0, task_ctx)?).await?;
8846+
assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 3);
8847+
let measured = dynamic_filter.removed_row_work().ns_per_row();
8848+
assert!(measured.is_some_and(|ns| ns > 0.0), "{measured:?}");
8849+
Ok(())
8850+
}
8851+
87538852
/// The [`PartitionMode::Partitioned`] counterpart of
87548853
/// [`test_null_equal_dynamic_filter_keeps_probe_nulls_for_build_logical_null`].
87558854
///

‎datafusion/physical-plan/src/joins/hash_join/stream.rs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,12 @@ use arrow::array::{Array, ArrayRef, UInt32Array, UInt64Array};
4949
use arrow::buffer::{BooleanBuffer, NullBuffer};
5050
use arrow::datatypes::{Schema, SchemaRef};
5151
use arrow::record_batch::RecordBatch;
52+
use datafusion_common::instant::Instant;
5253
use datafusion_common::{
5354
JoinSide, JoinType, NullEquality, Result, internal_datafusion_err, internal_err,
5455
};
5556
use datafusion_physical_expr::PhysicalExprRef;
57+
use datafusion_physical_expr::filter_stats::{RemovedRowWork, duration_nanos};
5658

5759
use datafusion_common::hash_utils::RandomState;
5860
use datafusion_physical_expr_common::utils::evaluate_expressions_to_arrays;
@@ -382,6 +384,14 @@ pub(super) struct HashJoinStream {
382384
batch_size: usize,
383385
/// Scratch space for computing hashes
384386
hashes_buffer: Vec<u64>,
387+
/// The work measurements of the dynamic filters that this join produces
388+
/// (see [`RemovedRowWork`]). The join records the rows of each probe
389+
/// batch and the time of the work that it does for a probe row whether
390+
/// or not the row matches: the evaluation and the hashes of the join
391+
/// keys and the hash table lookup. A row that the dynamic filter removes
392+
/// before the join does not get this work. Empty without dynamic filter
393+
/// pushdown.
394+
removed_row_work: Vec<Arc<RemovedRowWork>>,
385395
/// Scratch space for probe indices during hash lookup
386396
probe_indices_buffer: Vec<u32>,
387397
/// Scratch space for build indices during hash lookup
@@ -585,6 +595,7 @@ impl HashJoinStream {
585595
null_mark_hashes_buffer: Vec::new(),
586596
null_mark_probe_indices_buffer: Vec::new(),
587597
null_mark_build_indices_buffer: Vec::new(),
598+
removed_row_work: vec![],
588599
right_side_ordered,
589600
build_report: BuildReportHandle::new(partition, mode, build_accumulator),
590601
mode,
@@ -593,6 +604,24 @@ impl HashJoinStream {
593604
}
594605
}
595606

607+
/// Records the work for each probe row in `works`, see
608+
/// the `removed_row_work` field.
609+
pub(super) fn with_removed_row_work(
610+
mut self,
611+
works: Vec<Arc<RemovedRowWork>>,
612+
) -> Self {
613+
self.removed_row_work = works;
614+
self
615+
}
616+
617+
/// Records `rows` probe rows and `nanos` nanoseconds of work for each
618+
/// row in the dynamic filters of this join.
619+
fn record_removed_row_work(&self, rows: usize, nanos: u64) {
620+
for work in &self.removed_row_work {
621+
work.record(rows as u64, nanos);
622+
}
623+
}
624+
596625
/// Returns the next state after the build side has been fully collected
597626
/// and any required build-side coordination has completed.
598627
fn state_after_build_ready(
@@ -779,6 +808,7 @@ impl HashJoinStream {
779808
self.state = HashJoinStreamState::ExhaustedProbeSide;
780809
}
781810
Some(Ok(batch)) => {
811+
let work_start = (!self.removed_row_work.is_empty()).then(Instant::now);
782812
// Precalculate hash values for fetched batch
783813
let keys_values = evaluate_expressions_to_arrays(&self.on_right, &batch)?;
784814

@@ -797,6 +827,12 @@ impl HashJoinStream {
797827
None
798828
};
799829

830+
if let Some(work_start) = work_start {
831+
self.record_removed_row_work(
832+
batch.num_rows(),
833+
duration_nanos(work_start.elapsed()),
834+
);
835+
}
800836
self.join_metrics.input_batches.add(1);
801837
self.join_metrics.input_rows.add(batch.num_rows());
802838

@@ -881,6 +917,7 @@ impl HashJoinStream {
881917
}
882918

883919
// get the matched by join keys indices
920+
let work_start = (!self.removed_row_work.is_empty()).then(Instant::now);
884921
let (left_indices, right_indices, next_offset) = match build_side.left_data.map()
885922
{
886923
Map::HashMap(map) => lookup_join_hashmap(
@@ -911,6 +948,11 @@ impl HashJoinStream {
911948
}
912949
};
913950

951+
if let Some(work_start) = work_start {
952+
for work in &self.removed_row_work {
953+
work.record(0, duration_nanos(work_start.elapsed()));
954+
}
955+
}
914956
let matched_probe_rows = state.count_new_matched_probe_rows(&right_indices);
915957

916958
self.join_metrics

0 commit comments

Comments
 (0)