Pwmj metrics accounting - #24956
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24956 +/- ##
==========================================
+ Coverage 81.61% 81.63% +0.01%
==========================================
Files 1124 1124
Lines 411978 412700 +722
Branches 411978 412700 +722
==========================================
+ Hits 336236 336887 +651
- Misses 55936 55976 +40
- Partials 19806 19837 +31 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @SubhamSinghal , I found what look like duplicated timers in a few places. If there's a reason to keep them, a short comment explaining why would help.
| let buffered_data = Arc::clone(&self.buffered_side.try_as_ready()?.buffered_data); | ||
| let buffered_batch = buffered_data.batch(); | ||
|
|
||
| let join_timer = self.join_metrics.join_time.timer(); |
There was a problem hiding this comment.
Do we need another timer here?
| // nor miss -- counting it either way would understate the real hit rate. | ||
| if batch.num_rows() > 0 { | ||
| let join_time = self.join_metrics.join_time.clone(); | ||
| let join_timer = join_time.timer(); |
There was a problem hiding this comment.
Do we need either join_time or join_timer here?
| } | ||
|
|
||
| // Produce more work | ||
| let join_timer = self.join_metrics.join_time.timer(); |
There was a problem hiding this comment.
it starts timers inside existing timers
| let buffered_data = Arc::clone(&self.buffered_side.try_as_ready()?.buffered_data); | ||
| let buffered_batch = buffered_data.batch(); | ||
|
|
||
| let join_timer = self.join_metrics.join_time.timer(); |
| // An empty batch has no extreme key to compare, so it can neither match | ||
| // nor miss -- counting it either way would understate the real hit rate. | ||
| if batch.num_rows() > 0 { | ||
| let join_time = self.join_metrics.join_time.clone(); |
|
I found a possible issue
Since if row_idx < stream_values.len() && first_non_null_buffered < buffered_len {
let cmp = JoinKeyComparator::new(/* unchanged */)?;
let is_match = |buffer_idx: usize| { /* unchanged */ };
// `is_match` is monotone over the sorted buffered side, so the extreme key
// matches *something* iff it matches the last buffered key. That decides
// `probe_hit_rate` independently of the watermark, which other batches or
// partitions may already have lowered past this batch's match range. A
// batch that matches nothing can't lower the watermark either.
if !is_match(buffered_len - 1) {
return Ok(());
}
self.join_metrics.probe_hit_rate.add_part(1);
if first_non_null_buffered >= scan_limit {
// Everything this batch could mark is already marked.
return Ok(());
}
// existing binary search over [first_non_null_buffered, scan_limit) ...
let buffer_idx = lo;
if buffer_idx < scan_limit {
buffered_data.min_marked.fetch_min(buffer_idx, AtomicOrdering::SeqCst);
}
}The existing test then expects /// Runs a `LeftSemi` `buffered.b1 > streamed.b1` join over buffered keys 1..=5 with
/// the given streamed batches in one partition and returns `probe_hit_rate` as
/// `(part, total)`.
async fn existence_probe_hit_rate(
streamed_batches: Vec<RecordBatch>,
) -> Result<(usize, usize)> {
let left = build_table(
("a1", &vec![1, 2, 3, 4, 5]),
("b1", &vec![1, 2, 3, 4, 5]),
("c1", &vec![10, 20, 30, 40, 50]),
);
let streamed_schema = Schema::new(vec![
Field::new("a2", DataType::Int32, false),
Field::new("b1", DataType::Int32, false),
Field::new("c2", DataType::Int32, false),
]);
let right = TestMemoryExec::try_new_exec(
&[streamed_batches],
Arc::new(streamed_schema),
None,
)?;
let on = (
Arc::new(Column::new_with_schema("b1", &left.schema())?) as _,
Arc::new(Column::new_with_schema("b1", &right.schema())?) as _,
);
let join = PiecewiseMergeJoinExec::try_new(
left, right, on, Operator::Gt, JoinType::LeftSemi, 1,
)?;
let stream = join.execute(0, Arc::new(TaskContext::default()))?;
common::collect(stream).await?;
let metrics = join.metrics().unwrap();
Ok(metrics
.iter()
.find_map(|m| match m.value() {
crate::metrics::MetricValue::Ratio { name, ratio_metrics }
if name == "probe_hit_rate" =>
{
Some((ratio_metrics.part(), ratio_metrics.total()))
}
_ => None,
})
.expect("probe_hit_rate metric"))
}
/// `probe_hit_rate` must describe the data, not the order it arrived in. Key 3 matches
/// buffered 4 and 5; key 5 matches nothing under `>`. Whichever batch lowers the
/// watermark first, the answer is one hit out of two.
#[tokio::test]
async fn existence_probe_hit_rate_is_independent_of_batch_order() -> Result<()> {
let hit = || build_table_i32(("a2", &vec![10]), ("b1", &vec![3]), ("c2", &vec![70]));
let miss = || build_table_i32(("a2", &vec![20]), ("b1", &vec![5]), ("c2", &vec![80]));
assert_eq!(existence_probe_hit_rate(vec![hit(), miss()]).await?, (1, 2));
assert_eq!(existence_probe_hit_rate(vec![miss(), hit()]).await?, (1, 2));
// Two hits whose match ranges nest: the second cannot lower the watermark but
// still matched, so it must not be reported as a miss in either order.
let inner = || build_table_i32(("a2", &vec![30]), ("b1", &vec![4]), ("c2", &vec![90]));
assert_eq!(existence_probe_hit_rate(vec![hit(), inner()]).await?, (2, 2));
assert_eq!(existence_probe_hit_rate(vec![inner(), hit()]).await?, (2, 2));
Ok(())
}On the current branch this test fails at the nested-hits assertion with |
Which issue does this PR close?
Part of #17427.
Rationale for this change
PiecewiseMergeJoinExec's metrics accounting had several gaps left over from earlier PRs in the epic:Left/Right/Full/Inner) never routedpoll_nextthroughBaselineMetrics::record_poll, sooutput_rows,output_bytes, andoutput_batchesstayed at0inEXPLAIN ANALYZEregardless of how many rows the join actually produced.join_timewas never measured on either the classic or existence-join stream — onlybuild_timewas timed, soelapsed_computeunderstated total operator cost for large probe sides.probe_hit_rate/avg_fanoutexist on the sharedBuildProbeJoinMetricsstruct (populated byHashJoinExec) but PWMJ never populated them, always showingN/A (0/0).What changes are included in this PR?
ClassicPWMJStream::poll_nextnow callsself.join_metrics.baseline.record_poll(poll), matching the existence-join stream.join_timeis timed around the actual comparison work:resolve_classic_joinand theProcessUnmatchedbitmap/take pass on the classic path;extreme_key+mark_matched_buffered_rowson the existence path.probe_hit_rate/avg_fanoutare populated for classic join, using the range size (buffered_len - buffer_idx) already computed at each match as the fanout.probe_hit_rateis populated for existence join: a streamed batch counts as a hit if its extreme key lowers the shared watermark, a miss otherwise.avg_fanoutis intentionally left unset for existence join — its watermark-based semantics don't have a natural per-row fanout equivalent.classic_join::tests::inner_join_records_output_and_probe_metricsandexistence_join::tests::probe_hit_rate_counts_batches_that_advance_the_watermark, both hand-derived and verified against actual runs.Are these changes tested?
Yes — two new unit tests
Are there any user-facing changes?
EXPLAIN ANALYZEon aPiecewiseMergeJoinExecplan now reports accurateoutput_rows/output_bytes/output_batches/join_time/probe_hit_rate/avg_fanoutinstead of zeros/N/A. No API or behavior change to query results.