sparkforensics-server 0.6.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +16 -7
- package/package.json +1 -1
- package/public/assets/AutoscalingChurn-DenWKTv-.js +1 -0
- package/public/assets/CacheUtilization-DLz7NdjV.js +1 -0
- package/public/assets/CachingOpportunity-zpQIqhzm.js +1 -0
- package/public/assets/ChartTheme-DAiARbLP.js +8 -0
- package/public/assets/ColdStart-C-9HjxWl.js +1 -0
- package/public/assets/ConfigAudit-D0V9tcrJ.js +1 -0
- package/public/assets/CoreUsageArea-yeerrljd.js +1 -0
- package/public/assets/Dashboard-DkUPrHKV.js +1 -0
- package/public/assets/DisclosureContext-DK1_iz6J.js +1 -0
- package/public/assets/DuplicatePlanSubtree-xDNDbCd3.js +1 -0
- package/public/assets/DurationHistogram-B6jsE0Ov.js +1 -0
- package/public/assets/EmptyState-C1uL40VU.js +1 -0
- package/public/assets/ExecutorUtilization-DXDZKmSc.js +1 -0
- package/public/assets/GcPressure-MRf7I4IG.js +1 -0
- package/public/assets/ImpactEstimate-Dm3xWbzV.js +1 -0
- package/public/assets/IncompleteRun-CaLOhMtf.js +1 -0
- package/public/assets/JobFailures-CzmzlvhO.js +1 -0
- package/public/assets/MemoryUtilization-AWUMUjCH.js +1 -0
- package/public/assets/NestedLoopJoin-C9sNr6Ss.js +1 -0
- package/public/assets/OverBroadcast-BOmRE6ca.js +1 -0
- package/public/assets/PartitionHint-BL2EmJfE.js +1 -0
- package/public/assets/PartitionSizing-BzBySvUq.js +1 -0
- package/public/assets/PlanExplorer-DdiQRcJU.js +1 -0
- package/public/assets/PlanGraphRoute-CIH9gBuR.js +1 -0
- package/public/assets/PlanView-B1hxzFRs.js +2 -0
- package/public/assets/PythonUdf-DvgCCueD.js +1 -0
- package/public/assets/RetryWaste-uXXu_CyM.js +1 -0
- package/public/assets/RowPagination-C-LzWkI5.js +1 -0
- package/public/assets/RowStatusCluster-Dbtmte7l.js +1 -0
- package/public/assets/ShuffleIO-nALwzC1_.js +1 -0
- package/public/assets/Skew-CHMEKgvk.js +1 -0
- package/public/assets/SlowHost-yyvjpHk6.js +1 -0
- package/public/assets/SmallFiles-y6odJ97n.js +1 -0
- package/public/assets/SpeculationWaste-C-SD2LOt.js +1 -0
- package/public/assets/Spill-DHTMmOup.js +1 -0
- package/public/assets/StageFailed-CQ3DxC9R.js +1 -0
- package/public/assets/StageFindingGroup-BgYLyj5q.js +1 -0
- package/public/assets/StageHeader-CIETkjVZ.js +1 -0
- package/public/assets/StagePill--P7PDd3s.js +1 -0
- package/public/assets/StageShape-BIv8vx_o.js +1 -0
- package/public/assets/StageSlowness-MMqzERxN.js +1 -0
- package/public/assets/Straggler-BgrY7AyW.js +1 -0
- package/public/assets/TaskFailures-BHHyKYTs.js +1 -0
- package/public/assets/TinyTask-DDScNhCl.js +1 -0
- package/public/assets/UnderBroadcast-CGIC04TI.js +1 -0
- package/public/assets/WidgetLeadSummary-CXFZP00C.js +1 -0
- package/public/assets/button-DbpTz2yk.js +1 -0
- package/public/assets/charts-vendor-CtTxwTeu.js +59 -0
- package/public/assets/composite-C9Nu2EI9.js +1 -0
- package/public/assets/d3-vendor-DNlD2-cz.js +1 -0
- package/public/assets/detector-registry-Bp8hrLua.js +2 -0
- package/public/assets/downsample-WmNM1qc9.js +1 -0
- package/public/assets/finding-anchor-Dr40LkS9.js +1 -0
- package/public/assets/findings-of-type-BL2u3RGZ.js +1 -0
- package/public/assets/impact-sort-mC5ISgX8.js +1 -0
- package/public/assets/inbox-KPU1Gppm.js +1 -0
- package/public/assets/index-CaRVPvAT.css +2 -0
- package/public/assets/index-fDiwfuoO.js +14 -0
- package/public/assets/inertValue-L9VxV0VE.js +1 -0
- package/public/assets/layout.worker-CLcBHxVp.js +3 -0
- package/public/assets/{parser-worker-CA5RxqKc.js → parser-worker-CRIOPMOW.js} +8 -8
- package/public/assets/plan-graph-vendor-Ce6d4HU6.js +7 -0
- package/public/assets/react-vendor-C6KcUD36.js +9 -0
- package/public/assets/select-BHqOqun5.js +1 -0
- package/public/assets/toggle-DPeN6aNa.js +1 -0
- package/public/assets/toggle-group-Kl3uyHSr.js +1 -0
- package/public/assets/usePagedRows-BafD6bEN.js +1 -0
- package/public/assets/useRecentFiles-DzJZAoOT.js +3 -0
- package/public/assets/useRegisterFieldControl-pQv-ZDoU.js +1 -0
- package/public/assets/useScrollLock-BCxn_dMp.js +1 -0
- package/public/assets/useSortMode-DjrAN-z4.js +1 -0
- package/public/docs/404.html +3 -3
- package/public/docs/assets/{app.CnWL6bKV.js → app.1gvzd5Zb.js} +1 -1
- package/public/docs/assets/chunks/@localSearchIndexroot.DS2TQP5_.js +1 -0
- package/public/docs/assets/chunks/{VPLocalSearchBox.C0IBKeRQ.js → VPLocalSearchBox.CNxT3s0I.js} +1 -1
- package/public/docs/assets/chunks/{theme.BH-2HuRf.js → theme.C5lLqUv7.js} +2 -2
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.CqKM_jEG.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.CqKM_jEG.lean.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.CQgn1t_M.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.BYDo3ClR.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.ajUAVdbc.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_board-widgets_visual-system-and-templating.md.wulOueeB.js → contributor-guide_architecture_board-widgets_visual-system-and-templating.md.WqY74T0T.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.WqY74T0T.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_detector-contract.md.CHHgywoP.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_detector-contract.md.N4hhenrA.lean.js → contributor-guide_architecture_detector-contract.md.CHHgywoP.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.DIz3_oz9.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.DIz3_oz9.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.WH4tRHPL.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_detector-contract_threshold-tables.md.Dp3c40rx.lean.js → contributor-guide_architecture_detector-contract_threshold-tables.md.WH4tRHPL.lean.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_drill-down.md.CqZPrdo9.js → contributor-guide_architecture_drill-down.md.yB-Hz2XT.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_drill-down.md.CqZPrdo9.lean.js → contributor-guide_architecture_drill-down.md.yB-Hz2XT.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.BQZE04sw.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_drill-down_plan-graph.md.DwOozIEs.lean.js → contributor-guide_architecture_drill-down_plan-graph.md.BQZE04sw.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.K71YB-Rr.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_impact-estimation.md.DfvbqEUu.lean.js → contributor-guide_architecture_impact-estimation.md.K71YB-Rr.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.DLCAffwn.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.Cwm1jHmn.lean.js → contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.DLCAffwn.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.JFrzDMwo.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.JFrzDMwo.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_overview.md.nxkpOhgf.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.U-9KMDFK.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_state-and-history_run-comparison-internals.md.CZA0JLtV.lean.js → contributor-guide_architecture_state-and-history_run-comparison-internals.md.U-9KMDFK.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BrSz8Ezh.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.B7rjSF_h.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.B7rjSF_h.lean.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_widget-rendering_list-sort-mode.md.BuHKgH0C.js → contributor-guide_architecture_widget-rendering_list-sort-mode.md.37hcZJ7q.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_widget-rendering_row-expand-contract.md.ZT89u9f1.js → contributor-guide_architecture_widget-rendering_row-expand-contract.md.mK_VmAWk.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol.md.BAnhi2-d.js → contributor-guide_architecture_worker-protocol.md.Br6OE7RW.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.Br6OE7RW.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.IVyaXrGe.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_cli-and-mcp.md.BX1XnFbq.lean.js → contributor-guide_architecture_worker-protocol_cli-and-mcp.md.IVyaXrGe.lean.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DSVFPpWb.js → contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DVSXn-bb.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-availability.md.CjimIDBY.js → contributor-guide_architecture_worker-protocol_evidence-availability.md.D3S5cyOW.js} +2 -2
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-availability.md.CjimIDBY.lean.js → contributor-guide_architecture_worker-protocol_evidence-availability.md.D3S5cyOW.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.CtmCU57I.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-report.md.CiQl1MLb.lean.js → contributor-guide_architecture_worker-protocol_evidence-report.md.CtmCU57I.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_contributing.md.DBW4drtZ.js +1 -0
- package/public/docs/assets/contributor-guide_development-setup.md.B21ZTDhn.js +15 -0
- package/public/docs/assets/contributor-guide_development-setup.md.B21ZTDhn.lean.js +1 -0
- package/public/docs/assets/contributor-guide_testing.md.DpkozNoG.js +4 -0
- package/public/docs/assets/{contributor-guide_testing.md.By6XGR9a.lean.js → contributor-guide_testing.md.DpkozNoG.lean.js} +1 -1
- package/public/docs/assets/{index.md.CHJVslga.js → index.md.lMm7SUMz.js} +1 -1
- package/public/docs/assets/{index.md.CHJVslga.lean.js → index.md.lMm7SUMz.lean.js} +1 -1
- package/public/docs/assets/{style.CyK9Ss73.css → style.Cfxqlj0y.css} +1 -1
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.C0pcQoNc.js +29 -0
- package/public/docs/assets/{user-guide_getting-started_ci-and-automation.md.BEepbDDL.lean.js → user-guide_getting-started_ci-and-automation.md.C0pcQoNc.lean.js} +1 -1
- package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.CGaxPX_1.js +1 -0
- package/public/docs/assets/{user-guide_getting-started_tuning-thresholds.md.B_MyjyZm.js → user-guide_getting-started_tuning-thresholds.md.BGVE-wrc.js} +2 -2
- package/public/docs/assets/{user-guide_getting-started_tuning-thresholds.md.B_MyjyZm.lean.js → user-guide_getting-started_tuning-thresholds.md.BGVE-wrc.lean.js} +1 -1
- package/public/docs/assets/{user-guide_mcp-tools.md.C65SjWUL.js → user-guide_mcp-tools.md.49r3L2YE.js} +1 -1
- package/public/docs/assets/{user-guide_mcp-tools_compare-runs.md.Cc_gLoix.js → user-guide_mcp-tools_compare-runs.md.BQ_kIptC.js} +11 -6
- package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.BQ_kIptC.lean.js +1 -0
- package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.azCZXkH8.js +44 -0
- package/public/docs/assets/{user-guide_mcp-tools_diagnose-run.md.BP1vnHA5.lean.js → user-guide_mcp-tools_diagnose-run.md.azCZXkH8.lean.js} +1 -1
- package/public/docs/assets/{user-guide_mcp-tools_evaluate-budgets.md.BB0nDY3Z.js → user-guide_mcp-tools_evaluate-budgets.md.dpQpRQ7z.js} +2 -2
- package/public/docs/assets/{user-guide_mcp-tools_evaluate-budgets.md.BB0nDY3Z.lean.js → user-guide_mcp-tools_evaluate-budgets.md.dpQpRQ7z.lean.js} +1 -1
- package/public/docs/assets/{user-guide_mcp-tools_get-finding-evidence.md.rKyu4wts.js → user-guide_mcp-tools_get-finding-evidence.md.CH6FkNLX.js} +2 -1
- package/public/docs/assets/{user-guide_mcp-tools_get-finding-evidence.md.rKyu4wts.lean.js → user-guide_mcp-tools_get-finding-evidence.md.CH6FkNLX.lean.js} +1 -1
- package/public/docs/assets/user-guide_run-comparison.md.B_LOkdBf.js +1 -0
- package/public/docs/assets/user-guide_run-comparison.md.B_LOkdBf.lean.js +1 -0
- package/public/docs/assets/user-guide_understanding-findings.md.C4riKCKl.js +1 -0
- package/public/docs/assets/user-guide_understanding-findings.md.C4riKCKl.lean.js +1 -0
- package/public/docs/contributor-guide/architecture/board-widgets/caching-and-memory.html +6 -6
- package/public/docs/contributor-guide/architecture/board-widgets/plan-advisor.html +6 -6
- package/public/docs/contributor-guide/architecture/board-widgets/visual-system-and-templating.html +6 -6
- package/public/docs/contributor-guide/architecture/board-widgets.html +6 -6
- package/public/docs/contributor-guide/architecture/detector-contract/plan-attribution.html +6 -6
- package/public/docs/contributor-guide/architecture/detector-contract/threshold-tables.html +6 -6
- package/public/docs/contributor-guide/architecture/detector-contract.html +6 -6
- package/public/docs/contributor-guide/architecture/drill-down/plan-graph.html +6 -6
- package/public/docs/contributor-guide/architecture/drill-down.html +6 -6
- package/public/docs/contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.html +6 -6
- package/public/docs/contributor-guide/architecture/impact-estimation/stage-union-rollup.html +6 -6
- package/public/docs/contributor-guide/architecture/impact-estimation.html +6 -6
- package/public/docs/contributor-guide/architecture/index.html +5 -5
- package/public/docs/contributor-guide/architecture/overview.html +6 -6
- package/public/docs/contributor-guide/architecture/state-and-history/history-server-intake.html +5 -5
- package/public/docs/contributor-guide/architecture/state-and-history/run-comparison-internals.html +6 -6
- package/public/docs/contributor-guide/architecture/state-and-history.html +5 -5
- package/public/docs/contributor-guide/architecture/widget-rendering/findings-tab.html +6 -6
- package/public/docs/contributor-guide/architecture/widget-rendering/investigation-routing.html +5 -5
- package/public/docs/contributor-guide/architecture/widget-rendering/list-sort-mode.html +6 -6
- package/public/docs/contributor-guide/architecture/widget-rendering/reference-section.html +5 -5
- package/public/docs/contributor-guide/architecture/widget-rendering/row-expand-contract.html +6 -6
- package/public/docs/contributor-guide/architecture/widget-rendering.html +6 -6
- package/public/docs/contributor-guide/architecture/worker-protocol/cli-and-mcp.html +6 -6
- package/public/docs/contributor-guide/architecture/worker-protocol/core-and-event-validation.html +6 -6
- package/public/docs/contributor-guide/architecture/worker-protocol/evidence-availability.html +7 -7
- package/public/docs/contributor-guide/architecture/worker-protocol/evidence-report.html +6 -6
- package/public/docs/contributor-guide/architecture/worker-protocol.html +6 -6
- package/public/docs/contributor-guide/contributing.html +6 -6
- package/public/docs/contributor-guide/development-setup.html +8 -8
- package/public/docs/contributor-guide/testing.html +9 -6
- package/public/docs/hashmap.json +1 -1
- package/public/docs/index.html +6 -6
- package/public/docs/logo-dark.svg +1 -0
- package/public/docs/logo-light.svg +1 -0
- package/public/docs/tuning-reference/anti-patterns.html +5 -5
- package/public/docs/tuning-reference/aqe.html +5 -5
- package/public/docs/tuning-reference/bottleneck-broadcast-sizing.html +5 -5
- package/public/docs/tuning-reference/bottleneck-cold-start.html +5 -5
- package/public/docs/tuning-reference/bottleneck-duplicate-plan-subtree.html +5 -5
- package/public/docs/tuning-reference/bottleneck-failures.html +5 -5
- package/public/docs/tuning-reference/bottleneck-gc.html +5 -5
- package/public/docs/tuning-reference/bottleneck-job-failure-rate.html +5 -5
- package/public/docs/tuning-reference/bottleneck-memory-utilization.html +5 -5
- package/public/docs/tuning-reference/bottleneck-retry-waste.html +5 -5
- package/public/docs/tuning-reference/bottleneck-shuffle.html +5 -5
- package/public/docs/tuning-reference/bottleneck-skew.html +5 -5
- package/public/docs/tuning-reference/bottleneck-slow-host.html +5 -5
- package/public/docs/tuning-reference/bottleneck-small-files.html +5 -5
- package/public/docs/tuning-reference/bottleneck-spill.html +5 -5
- package/public/docs/tuning-reference/bottleneck-straggler.html +5 -5
- package/public/docs/tuning-reference/bottleneck-tiny-tasks.html +5 -5
- package/public/docs/tuning-reference/bottleneck-utilization.html +5 -5
- package/public/docs/tuning-reference/caching.html +5 -5
- package/public/docs/tuning-reference/cluster-config.html +5 -5
- package/public/docs/tuning-reference/config.html +5 -5
- package/public/docs/tuning-reference/data-formats.html +5 -5
- package/public/docs/tuning-reference/index.html +5 -5
- package/public/docs/tuning-reference/intro.html +5 -5
- package/public/docs/tuning-reference/joins.html +5 -5
- package/public/docs/tuning-reference/memory-model.html +5 -5
- package/public/docs/tuning-reference/metrics.html +5 -5
- package/public/docs/tuning-reference/partitioning.html +5 -5
- package/public/docs/tuning-reference/pyspark.html +5 -5
- package/public/docs/tuning-reference/shuffle.html +5 -5
- package/public/docs/tuning-reference/spark-architecture.html +5 -5
- package/public/docs/tuning-reference/table-formats.html +5 -5
- package/public/docs/user-guide/alternative-log-retrieval/managed-platforms.html +5 -5
- package/public/docs/user-guide/alternative-log-retrieval/ssh-bastion.html +5 -5
- package/public/docs/user-guide/alternative-log-retrieval.html +5 -5
- package/public/docs/user-guide/getting-started/ci-and-automation.html +16 -11
- package/public/docs/user-guide/getting-started/reading-the-dashboard.html +6 -6
- package/public/docs/user-guide/getting-started/regression-metric-keys.html +5 -5
- package/public/docs/user-guide/getting-started/tuning-thresholds.html +7 -7
- package/public/docs/user-guide/getting-started.html +5 -5
- package/public/docs/user-guide/mcp-tools/compare-runs.html +16 -11
- package/public/docs/user-guide/mcp-tools/diagnose-run.html +28 -7
- package/public/docs/user-guide/mcp-tools/evaluate-budgets.html +6 -6
- package/public/docs/user-guide/mcp-tools/get-finding-documentation.html +5 -5
- package/public/docs/user-guide/mcp-tools/get-finding-evidence.html +7 -6
- package/public/docs/user-guide/mcp-tools/get-reference-doc.html +5 -5
- package/public/docs/user-guide/mcp-tools/get-run-summary.html +5 -5
- package/public/docs/user-guide/mcp-tools/list-runs.html +5 -5
- package/public/docs/user-guide/mcp-tools.html +6 -6
- package/public/docs/user-guide/run-comparison/how-stages-are-matched.html +5 -5
- package/public/docs/user-guide/run-comparison.html +6 -6
- package/public/docs/user-guide/understanding-findings.html +6 -6
- package/public/export-template.html +45 -45
- package/public/index.html +8 -7
- package/public/sample-runs/sample-run.ndjson.gz +0 -0
- package/vendor-core/allocation.js +25 -23
- package/vendor-core/analyzer.js +85 -58
- package/vendor-core/api-merge-writes.js +2 -0
- package/vendor-core/aqe-skew.js +347 -0
- package/vendor-core/cli/budgets.js +6 -4
- package/vendor-core/cli/collect-run-pair.js +89 -0
- package/vendor-core/cli/collect-run-worker.js +16 -0
- package/vendor-core/cli/collect-run.js +1 -2
- package/vendor-core/cli/native-zstd.js +12 -16
- package/vendor-core/comparability.js +234 -0
- package/vendor-core/comparison-output.js +15 -7
- package/vendor-core/comparison-verdict.js +105 -25
- package/vendor-core/core-source-hash.txt +1 -1
- package/vendor-core/core-time-series.js +0 -1
- package/vendor-core/core-usage-locality.js +1 -1
- package/vendor-core/detectors.js +1007 -380
- package/vendor-core/docs-config.js +0 -4
- package/vendor-core/docs-content/detection/cache.md +2 -1
- package/vendor-core/docs-content/detection/cfg.md +19 -11
- package/vendor-core/docs-content/detection/cold.md +2 -1
- package/vendor-core/docs-content/detection/gc.md +7 -3
- package/vendor-core/docs-content/detection/host.md +11 -1
- package/vendor-core/docs-content/detection/mem.md +17 -6
- package/vendor-core/docs-content/detection/part.md +3 -1
- package/vendor-core/docs-content/detection/plan.md +55 -13
- package/vendor-core/docs-content/detection/shfl.md +11 -5
- package/vendor-core/docs-content/detection/skew.md +47 -1
- package/vendor-core/docs-content/detection/slow.md +4 -2
- package/vendor-core/docs-content/detection/spec.md +6 -1
- package/vendor-core/docs-content/detection/spill.md +6 -3
- package/vendor-core/docs-content/detection/strag.md +19 -6
- package/vendor-core/docs-content/detection/tiny.md +9 -3
- package/vendor-core/effective-conf.js +7 -10
- package/vendor-core/efficiency-model.js +2 -2
- package/vendor-core/etl-phases.js +1 -2
- package/vendor-core/event-handlers.js +175 -57
- package/vendor-core/event-schemas.js +21 -16
- package/vendor-core/evidence-availability.js +9 -2
- package/vendor-core/evidence-report.js +118 -35
- package/vendor-core/executor-peaks.js +41 -0
- package/vendor-core/export-data.js +5 -5
- package/vendor-core/finding-identity.js +78 -0
- package/vendor-core/finding-presentation.js +116 -33
- package/vendor-core/finding-types.js +88 -21
- package/vendor-core/format-utils.js +14 -15
- package/vendor-core/html-export.js +1 -1
- package/vendor-core/impact-band.js +2 -2
- package/vendor-core/impact-format.js +47 -19
- package/vendor-core/impact-model.js +26 -15
- package/vendor-core/job-groups.js +4 -4
- package/vendor-core/mcp-server-factory.js +48 -12
- package/vendor-core/mcp-tools.js +179 -24
- package/vendor-core/median.js +2 -2
- package/vendor-core/nested-loop-join.js +73 -0
- package/vendor-core/occupancy.js +1 -2
- package/vendor-core/parser-worker.js +1 -1
- package/vendor-core/plan-graph-model.js +14 -22
- package/vendor-core/plan-node-detail.js +2 -3
- package/vendor-core/plan-summary.js +24 -19
- package/vendor-core/plan-tree-walk.js +6 -8
- package/vendor-core/python-stage.js +12 -1
- package/vendor-core/recommendation-rollup.js +13 -12
- package/vendor-core/redact.js +52 -46
- package/vendor-core/run-aggregates.js +6 -8
- package/vendor-core/run-comparison.js +226 -66
- package/vendor-core/run-identity.js +12 -0
- package/vendor-core/run-interpretation.js +21 -0
- package/vendor-core/run-metrics.js +2 -2
- package/vendor-core/run-output.js +32 -10
- package/vendor-core/run-totals.js +12 -0
- package/vendor-core/run-verdict.js +76 -3
- package/vendor-core/scaling-sim.js +3 -4
- package/vendor-core/snappy-block.js +2 -2
- package/vendor-core/spark-conf.js +168 -0
- package/vendor-core/sql-stages.js +2 -2
- package/vendor-core/stage-alignment.js +2 -12
- package/vendor-core/stage-identity.js +58 -3
- package/vendor-core/stage-label.js +14 -0
- package/vendor-core/stage-plan-nodes.js +17 -3
- package/vendor-core/stage-quantiles.js +141 -9
- package/vendor-core/stage-structure.js +17 -3
- package/vendor-core/threshold-overrides.js +9 -3
- package/vendor-core/types.js +58 -21
- package/vendor-core/wasted-core-hours.js +0 -1
- package/vendor-core/write-targets.js +114 -14
- package/vendor-core/zstd-worker-client.js +1 -1
- package/vendor-core/zstd-worker.js +1 -1
- package/public/assets/AutoscalingChurn-CRA2Mb7F.js +0 -1
- package/public/assets/CacheUtilization-Bh9OY_V9.js +0 -1
- package/public/assets/CachingOpportunity-oCiQwmTJ.js +0 -1
- package/public/assets/ColdStart-BJrtjfmn.js +0 -1
- package/public/assets/ConfigAudit-DMvKxySP.js +0 -1
- package/public/assets/CoreUsageArea-REC2zsKw.js +0 -1
- package/public/assets/Dashboard-BHZ76JCu.js +0 -2
- package/public/assets/DisclosureContext-OO7X1HAx.js +0 -1
- package/public/assets/DuplicatePlanSubtree-BgG-L-cK.js +0 -1
- package/public/assets/ExecutorUtilization-D2KhSVbY.js +0 -1
- package/public/assets/GcPressure-D1BTE8qh.js +0 -1
- package/public/assets/IncompleteRun-DD40xUpy.js +0 -1
- package/public/assets/JobFailures-YwNzCF19.js +0 -1
- package/public/assets/MemoryUtilization-9b8ee_3_.js +0 -1
- package/public/assets/OverBroadcast-CfctS8a8.js +0 -1
- package/public/assets/PartitionHint-CUvbnnxy.js +0 -1
- package/public/assets/PartitionSizing-VG8kBgG5.js +0 -1
- package/public/assets/PlanExplorer-DmubTAOP.js +0 -1
- package/public/assets/PlanGraphRoute-DctPZjS0.js +0 -1
- package/public/assets/RetryWaste-CnpM8bB7.js +0 -1
- package/public/assets/ShuffleIO-D13vGmas.js +0 -1
- package/public/assets/Skew-DlagstYP.js +0 -1
- package/public/assets/SlowHost-cSkLRZ_e.js +0 -1
- package/public/assets/SmallFiles-B-kERMcq.js +0 -1
- package/public/assets/SpeculationWaste-DaysAzXO.js +0 -1
- package/public/assets/Spill-H44Gz4US.js +0 -1
- package/public/assets/StageFailed-DCfii9h_.js +0 -1
- package/public/assets/StageFindingGroup-BXJ0Mj5n.js +0 -1
- package/public/assets/StageHeader--oN2J-oD.js +0 -1
- package/public/assets/StageShape-IZ80CZiJ.js +0 -1
- package/public/assets/StageSlowness--BRIaBc1.js +0 -1
- package/public/assets/Straggler-Ctq0Df_o.js +0 -1
- package/public/assets/TaskFailures-Bks_gqhL.js +0 -1
- package/public/assets/TinyTask-BssfzGFB.js +0 -1
- package/public/assets/UnderBroadcast-DsScrxc-.js +0 -1
- package/public/assets/charts-vendor-0VMaLVLN.js +0 -59
- package/public/assets/finding-anchor-BYW1oz5R.js +0 -1
- package/public/assets/impact-sort-xzy0g-Vh.js +0 -1
- package/public/assets/index-DhpRGMZc.css +0 -2
- package/public/assets/index-DwqkFUFk.js +0 -14
- package/public/assets/plan-graph-vendor-BYl-AzN5.js +0 -7
- package/public/assets/react-vendor-DfpuPQZf.js +0 -9
- package/public/assets/select-C8edjYTq.js +0 -1
- package/public/assets/usePagedRows-CsuUFeYx.js +0 -1
- package/public/assets/useRecentFiles-BvkKo3JN.js +0 -11
- package/public/assets/useSortMode-joX71BZN.js +0 -1
- package/public/docs/assets/chunks/@localSearchIndexroot.tE5pz54z.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.YQcj5EI4.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.YQcj5EI4.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.RlzvUmNm.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.wulOueeB.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract.md.N4hhenrA.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.Dp3c40rx.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.DwOozIEs.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.DfvbqEUu.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.Cwm1jHmn.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_overview.md.C1sSvm9i.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.CZA0JLtV.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BOZ1azXG.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.BAnhi2-d.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.BX1XnFbq.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.CiQl1MLb.js +0 -1
- package/public/docs/assets/contributor-guide_contributing.md.CQMvGwFt.js +0 -1
- package/public/docs/assets/contributor-guide_development-setup.md.DQdzera7.js +0 -15
- package/public/docs/assets/contributor-guide_development-setup.md.DQdzera7.lean.js +0 -1
- package/public/docs/assets/contributor-guide_testing.md.By6XGR9a.js +0 -1
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.BEepbDDL.js +0 -24
- package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.Obf3W3__.js +0 -1
- package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.Cc_gLoix.lean.js +0 -1
- package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.BP1vnHA5.js +0 -23
- package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.js +0 -1
- package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.lean.js +0 -1
- package/public/docs/assets/user-guide_understanding-findings.md.3C7KCEZO.js +0 -1
- package/public/docs/assets/user-guide_understanding-findings.md.3C7KCEZO.lean.js +0 -1
- /package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.CQgn1t_M.lean.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.BYDo3ClR.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_board-widgets_plan-advisor.md.RlzvUmNm.lean.js → contributor-guide_architecture_board-widgets_plan-advisor.md.ajUAVdbc.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_overview.md.C1sSvm9i.lean.js → contributor-guide_architecture_overview.md.nxkpOhgf.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_widget-rendering.md.BOZ1azXG.lean.js → contributor-guide_architecture_widget-rendering.md.BrSz8Ezh.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_widget-rendering_list-sort-mode.md.BuHKgH0C.lean.js → contributor-guide_architecture_widget-rendering_list-sort-mode.md.37hcZJ7q.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_widget-rendering_row-expand-contract.md.ZT89u9f1.lean.js → contributor-guide_architecture_widget-rendering_row-expand-contract.md.mK_VmAWk.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DSVFPpWb.lean.js → contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DVSXn-bb.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_contributing.md.CQMvGwFt.lean.js → contributor-guide_contributing.md.DBW4drtZ.lean.js} +0 -0
- /package/public/docs/assets/{user-guide_getting-started_reading-the-dashboard.md.Obf3W3__.lean.js → user-guide_getting-started_reading-the-dashboard.md.CGaxPX_1.lean.js} +0 -0
- /package/public/docs/assets/{user-guide_mcp-tools.md.C65SjWUL.lean.js → user-guide_mcp-tools.md.49r3L2YE.lean.js} +0 -0
package/vendor-core/detectors.js
CHANGED
|
@@ -1,33 +1,38 @@
|
|
|
1
|
-
import { pathBasename, formatBytes, IMPACT_BAND_ORDER, MS_PER_CORE_HOUR } from './format-utils.js';
|
|
1
|
+
import { pathBasename, formatBytes, formatDuration, IMPACT_BAND_ORDER, MS_PER_CORE_HOUR } from './format-utils.js';
|
|
2
2
|
import { medianOfSorted } from './median.js';
|
|
3
3
|
import { shareLabel } from './finding-presentation.js';
|
|
4
|
-
import { scanRelationId } from './plan-summary.js';
|
|
4
|
+
import { scanRelationId, parseJoinType } from './plan-summary.js';
|
|
5
5
|
import { allocatedCoreMs, computeAllocation } from './allocation.js';
|
|
6
|
-
import {
|
|
6
|
+
import { executorHeapPeaks } from './executor-peaks.js';
|
|
7
|
+
import { computePeakConcurrentCores } from './core-count.js';
|
|
7
8
|
import { walkPlanTree } from './plan-tree-walk.js';
|
|
9
|
+
import { diagnoseJoinSkew, isSkewJoinNode, planShowsSkewSplit } from './aqe-skew.js';
|
|
10
|
+
import { isBatchEvalPythonNode } from './python-stage.js';
|
|
8
11
|
import { computeCoreLocalityRatio } from './core-locality-ratio.js';
|
|
12
|
+
import { STRAGGLER_FACTOR } from './stage-quantiles.js';
|
|
9
13
|
import { tailRecoveryMs, tailRemovedWorkMs, stragglerFixLongestTaskMs, } from './occupancy.js';
|
|
10
14
|
import { IMPACT_FLOOR_PCT_WARN, IMPACT_FLOOR_PCT_CRIT, appDurationMs } from './impact-band.js';
|
|
11
15
|
import {
|
|
12
16
|
BROADCAST_BANDWIDTH_BPS, EXECUTOR_STARTUP_OVERHEAD_MS, FILE_OPEN_OVERHEAD_MS, IDEAL_BYTES_PER_PARTITION_TASK,
|
|
13
17
|
NETWORK_FETCH_PENALTY_MS, RE_READ_THROUGHPUT_BPS, SHUFFLE_THROUGHPUT_BPS, SPILL_IO_THROUGHPUT_BPS, TAIL_CLAIM,
|
|
14
18
|
TASK_SCHEDULING_OVERHEAD_MS, costOnly, fetchWaitWallClockMs, measuredTaskOverhead, multiStageImpact, noWasteModel,
|
|
15
|
-
retryWallClockMs, singleStageImpact, stageIoParallelism, stageMappableWasteOrCostOnly, tasksMostlyIdle,
|
|
19
|
+
ioModelAssumption, retryWallClockMs, singleStageImpact, stageIoParallelism, stageMappableWasteOrCostOnly, tasksMostlyIdle, withAssumptions,
|
|
16
20
|
|
|
17
21
|
} from './impact-model.js';
|
|
18
22
|
import { isExchangeNode, isBroadcastExchangeNode } from './plan-node-detail.js';
|
|
23
|
+
import { outputRowsOf, ownOutputRows, parseNestedLoopJoin } from './nested-loop-join.js';
|
|
19
24
|
import { totalExecutorCpuMs } from './run-totals.js';
|
|
20
25
|
import { DUPLICATE_SUBTREE_DIFFERING_NOTE, duplicateSubtreeDetail, SLOW_HOST_DIMENSION_LABEL } from './finding-generic-recommendation.js';
|
|
21
26
|
import { stageIdsForSqlExec } from './sql-stages.js';
|
|
22
27
|
import { cyrb53 } from './string-hash.js';
|
|
23
28
|
import { codeFix, decreaseConf, increaseConf, setConf } from './remediation.js';
|
|
29
|
+
import { effectiveSparkConf, executorOverheadSettings, overlayModifiedConfigs, parseSparkBytes } from './spark-conf.js';
|
|
24
30
|
import { MAX_FAILURE_GROUPS, describeTaskFailure, } from './task-failure.js';
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
|
|
32
|
+
|
|
27
33
|
|
|
28
34
|
const MB = 1024 * 1024;
|
|
29
35
|
const GB = 1024 * MB;
|
|
30
|
-
const TB = 1024 * GB;
|
|
31
36
|
|
|
32
37
|
// Labels a byte threshold in this file's binary units, so a 1 GiB default reads '1 GB'.
|
|
33
38
|
function binaryThresholdLabel(bytes ) {
|
|
@@ -45,6 +50,9 @@ function binaryThresholdLabel(bytes ) {
|
|
|
45
50
|
|
|
46
51
|
|
|
47
52
|
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
48
56
|
|
|
49
57
|
|
|
50
58
|
|
|
@@ -93,6 +101,7 @@ function binaryThresholdLabel(bytes ) {
|
|
|
93
101
|
|
|
94
102
|
|
|
95
103
|
|
|
104
|
+
|
|
96
105
|
|
|
97
106
|
|
|
98
107
|
|
|
@@ -106,6 +115,8 @@ function binaryThresholdLabel(bytes ) {
|
|
|
106
115
|
|
|
107
116
|
|
|
108
117
|
|
|
118
|
+
|
|
119
|
+
|
|
109
120
|
|
|
110
121
|
|
|
111
122
|
|
|
@@ -157,16 +168,33 @@ function binaryThresholdLabel(bytes ) {
|
|
|
157
168
|
|
|
158
169
|
|
|
159
170
|
|
|
160
|
-
|
|
171
|
+
|
|
161
172
|
|
|
162
173
|
|
|
163
174
|
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
164
178
|
|
|
165
179
|
|
|
166
180
|
// auditConfig(app) calls every config-scope detect({ app }) with whatever appModel.app is
|
|
167
181
|
// (SparkAppInfo | null), independent of DetectorCtx.
|
|
168
182
|
|
|
169
183
|
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
// True when any stage ran outside a SQL execution: RDD API work, which spark.serializer governs.
|
|
190
|
+
export function hasStageOutsideSql(stages ) {
|
|
191
|
+
if (stages == null) return false;
|
|
192
|
+
for (const stage of stages.values()) if (stage.sqlExecutionId == null) return true;
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
170
198
|
|
|
171
199
|
|
|
172
200
|
|
|
@@ -177,13 +205,13 @@ function binaryThresholdLabel(bytes ) {
|
|
|
177
205
|
|
|
178
206
|
|
|
179
207
|
|
|
180
|
-
//
|
|
208
|
+
// Spill magnitude: absolute-volume tiers first, then ratio-plus-floor skew tiers.
|
|
181
209
|
function computeSpillMagnitude(
|
|
182
210
|
stage ,
|
|
183
211
|
t ,
|
|
184
212
|
) {
|
|
185
213
|
const { taskCount, spillDiskMax = 0, spillMemMax = 0, spillDiskP50 = 0, spillMemP50 = 0 } = stage;
|
|
186
|
-
//
|
|
214
|
+
// Absolute volume, branching on task count.
|
|
187
215
|
if (taskCount === 1) {
|
|
188
216
|
if (spillDiskMax >= t.singleTaskDiskGiB * GB || spillMemMax >= t.singleTaskMemGiB * GB) return { magnitude: 'severe' };
|
|
189
217
|
} else {
|
|
@@ -191,7 +219,7 @@ function computeSpillMagnitude(
|
|
|
191
219
|
if (spillDiskMax >= t.highDiskGiB * GB || perTaskDisk >= t.highTaskDiskMB * MB || spillMemMax >= t.highMemGiB * GB) return { magnitude: 'high' };
|
|
192
220
|
if (spillDiskMax >= t.medDiskMB * MB || spillMemMax >= t.medMemGiB * GB) return { magnitude: 'medium' };
|
|
193
221
|
}
|
|
194
|
-
//
|
|
222
|
+
// Ratio plus floor skew (requires enough tasks).
|
|
195
223
|
if (taskCount >= t.skewMinTasks) {
|
|
196
224
|
if (spillDiskP50 > 0 && spillDiskMax / spillDiskP50 > t.skewRatio && spillDiskMax >= t.skewDiskFloorMB * MB) return { magnitude: 'high' };
|
|
197
225
|
if (spillMemP50 > 0 && spillMemMax / spillMemP50 > t.skewRatio && spillMemMax >= t.skewMemFloorMB * MB) return { magnitude: 'medium' };
|
|
@@ -215,7 +243,7 @@ export function unionStageIds(nodes , fallback ) {
|
|
|
215
243
|
|
|
216
244
|
// Bottom-up shape computation for duplicate-subtree detection and the
|
|
217
245
|
// cachingOpportunity composite detector. `size` is the subtree node count; the default
|
|
218
|
-
// fingerprint encodes operator name + sorted metric NAMES (never values
|
|
246
|
+
// fingerprint encodes operator name + sorted metric NAMES (never values) + a digest
|
|
219
247
|
// of each child's fingerprint, so two subtrees with the same shape but different values still
|
|
220
248
|
// collide. Digesting children keeps each fingerprint O(own size): embedding the full child
|
|
221
249
|
// strings made every node carry its whole subtree, O(n x depth) text on deep real plans.
|
|
@@ -225,6 +253,13 @@ export function unionStageIds(nodes , fallback ) {
|
|
|
225
253
|
// without descendant scan detail entering the comparison.
|
|
226
254
|
|
|
227
255
|
|
|
256
|
+
// The metric names a node's fingerprint is built from. Executor-side values are left out: whether
|
|
257
|
+
// one resolves depends on which tasks reported it, and a fingerprint (and the duplicatePlanSubtree
|
|
258
|
+
// and cachingOpportunity ids derived from it) must not shift when a run reports more of them.
|
|
259
|
+
function fingerprintMetricNames(metrics ) {
|
|
260
|
+
return (metrics ?? []).filter((m) => !m.executorSide).map((m) => m.name).sort().join(',');
|
|
261
|
+
}
|
|
262
|
+
|
|
228
263
|
export function computePlanShapes(
|
|
229
264
|
root ,
|
|
230
265
|
opts = {},
|
|
@@ -249,7 +284,7 @@ export function computePlanShapes(
|
|
|
249
284
|
const realMetrics = writeHalf ? writeHalf.metrics : node.metrics;
|
|
250
285
|
const childShapes = realChildren.map((c) => visit(c, false));
|
|
251
286
|
const size = 1 + childShapes.reduce((sum, c) => sum + c.size, 0);
|
|
252
|
-
const metricNames = (realMetrics
|
|
287
|
+
const metricNames = fingerprintMetricNames(realMetrics);
|
|
253
288
|
const childFingerprints = childShapes.map((c) => cyrb53(c.fingerprint)).join(',');
|
|
254
289
|
const fingerprint = isRoot && includeDetail
|
|
255
290
|
? `${node.name}[${metricNames}]<${normalize(node.detail ?? '')}>{${childFingerprints}}`
|
|
@@ -274,8 +309,7 @@ export function normalizeDetail(detail ) {
|
|
|
274
309
|
.replace(/\bBuild(Left|Right)\b/g, 'BuildSide');
|
|
275
310
|
// The lookbehind only skips starts inside an identifier, which can't match unless the
|
|
276
311
|
// identifier's own start already did: same output, without re-scanning each identifier from
|
|
277
|
-
// every one of its letters (O(length^2) on
|
|
278
|
-
// log; 64ms -> 40ms per analyze()).
|
|
312
|
+
// every one of its letters (O(length^2) on long join details).
|
|
279
313
|
s = s.replace(/(?<![A-Za-z_])([A-Za-z_][\w.]*)\s*=\s*([A-Za-z_][\w.]*)/g, (_m, l, r) => {
|
|
280
314
|
const [a, b] = [l, r].sort();
|
|
281
315
|
return `${a} = ${b}`;
|
|
@@ -331,7 +365,7 @@ export function findCompositeCandidates(root ) {
|
|
|
331
365
|
const childResults = (node.children ?? []).map(visit);
|
|
332
366
|
path.pop();
|
|
333
367
|
|
|
334
|
-
const metricNames = (node.metrics
|
|
368
|
+
const metricNames = fingerprintMetricNames(node.metrics);
|
|
335
369
|
// Child digests, as in computePlanShapes: deterministic, so still comparable across executions.
|
|
336
370
|
const childFingerprints = childResults.map((r) => cyrb53(r.fingerprint)).join(',');
|
|
337
371
|
const fingerprint = `${node.name}[${metricNames}]{${childFingerprints}}`;
|
|
@@ -398,11 +432,8 @@ export function findDuplicateSubtrees(
|
|
|
398
432
|
{ minSubtreeSize, minOccurrences } ,
|
|
399
433
|
) {
|
|
400
434
|
const { shapeOf, allNodes } = computePlanShapes(root);
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// write-half node is ever pushed into allNodes in the first place, this
|
|
404
|
-
// filter can structurally never exclude anything. Kept in case that
|
|
405
|
-
// invariant ever changes upstream.
|
|
435
|
+
// Guard only: computePlanShapes steps through a read node to its write half's children, so no
|
|
436
|
+
// write-half node reaches allNodes.
|
|
406
437
|
const eligible = allNodes.filter(n => shapeOf.get(n) .size >= minSubtreeSize && n.exchangeRole !== 'write');
|
|
407
438
|
|
|
408
439
|
const groups = new Map ();
|
|
@@ -446,7 +477,7 @@ export function findDuplicateSubtrees(
|
|
|
446
477
|
// scanned table, literals). The fingerprint ignores detail, so occurrences can be same-shaped
|
|
447
478
|
// branches over different data; only identical ones are repeated work that computing once would
|
|
448
479
|
// save. AQE query-stage numbers (`ShuffleQueryStage 718` vs `720`) name the same computation's
|
|
449
|
-
// runtime stage and are ignored too.
|
|
480
|
+
// runtime stage and are ignored too. Calibration: threshold-tables.md.
|
|
450
481
|
function occurrencesHaveIdenticalDetails(occurrences ) {
|
|
451
482
|
const detailsOf = (root ) => {
|
|
452
483
|
const out = [];
|
|
@@ -470,11 +501,10 @@ function occurrencesHaveIdenticalDetails(occurrences ) {
|
|
|
470
501
|
|
|
471
502
|
// Stages that run nothing but the duplicated occurrences' operators: every operator attributed to
|
|
472
503
|
// the stage is inside `nodes`. A stage shared with operators outside them (the join consuming the
|
|
473
|
-
// subtree, the other join side) does other work too, so its time isn't the subtree's to claim
|
|
474
|
-
// counting it
|
|
504
|
+
// subtree, the other join side) does other work too, so its time isn't the subtree's to claim, and
|
|
505
|
+
// counting it would let sibling groups claim the same stage twice. A WholeStageCodegen wrapper and
|
|
475
506
|
// an Exchange's write half aren't other work (the fused pipeline around an operator, the shuffle
|
|
476
|
-
// write of its output), so they're left out of both counts
|
|
477
|
-
// only outside node on 426 stages.
|
|
507
|
+
// write of its output), so they're left out of both counts.
|
|
478
508
|
function countsTowardStage(node ) {
|
|
479
509
|
return node.exchangeRole !== 'write' && !node.name.startsWith('WholeStageCodegen');
|
|
480
510
|
}
|
|
@@ -517,40 +547,119 @@ const FILES_READ_BYTES = 'size of files read';
|
|
|
517
547
|
const FILES_WRITTEN_COUNT = 'number of written files';
|
|
518
548
|
const FILES_WRITTEN_BYTES = 'written output';
|
|
519
549
|
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
550
|
+
// A join side's shuffle: the Exchange node whose "data size" is that side's runtime size. AQE
|
|
551
|
+
// decides broadcast from the same metric (ShuffleExchangeExec.runtimeStatistics, apache/spark
|
|
552
|
+
// v3.5.0). Only operators that pass rows through unchanged are looked through, so a side that is
|
|
553
|
+
// another join's output, an aggregate, or a reused exchange has no size of its own and yields null.
|
|
554
|
+
const SHUFFLE_PASS_THROUGH = /^(Sort|InputAdapter|WholeStageCodegen|ShuffleQueryStage|AQEShuffleRead|CustomShuffleReader)\b/;
|
|
555
|
+
|
|
556
|
+
function joinSideShuffle(node ) {
|
|
557
|
+
if (node.name === 'ReusedExchange') return null;
|
|
558
|
+
const dataSize = (node.metrics ?? []).some((m) => m.name === 'data size');
|
|
559
|
+
if (isExchangeNode(node) && !isBroadcastExchangeNode(node.name) && dataSize) return node;
|
|
560
|
+
const children = node.children ?? [];
|
|
561
|
+
if (children.length !== 1 || !(isExchangeNode(node) || SHUFFLE_PASS_THROUGH.test(node.name))) return null;
|
|
562
|
+
return joinSideShuffle(children[0]);
|
|
529
563
|
}
|
|
530
564
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
565
|
+
function shuffleBytes(exchange ) {
|
|
566
|
+
return (exchange.metrics ?? []).find((m) => m.name === 'data size')?.value ?? 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// The join types a side can be the broadcast build side of, per canBuildBroadcastLeft/Right in
|
|
570
|
+
// JoinSelectionHelper (apache/spark v3.5.0, v4.0.0 adds LeftSingle on the right). A full outer
|
|
571
|
+
// join builds from neither side, and an unrecognized type is not guessed at.
|
|
572
|
+
const BROADCAST_BUILD_LEFT = new Set(['Inner', 'Cross', 'RightOuter']);
|
|
573
|
+
const BROADCAST_BUILD_RIGHT = new Set(['Inner', 'Cross', 'LeftOuter', 'LeftSingle', 'LeftSemi', 'LeftAnti', 'ExistenceJoin']);
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
// The smaller of the sides this join's type can broadcast. Both sides need a measurable size of
|
|
583
|
+
// their own, so the candidate is never compared with an unknown partner.
|
|
584
|
+
function broadcastCandidate(join ) {
|
|
585
|
+
const joinType = parseJoinType(join.detail ?? '');
|
|
586
|
+
if (joinType == null || (join.children ?? []).length !== 2) return null;
|
|
587
|
+
const left = joinSideShuffle(join.children[0]);
|
|
588
|
+
const right = joinSideShuffle(join.children[1]);
|
|
589
|
+
if (!left || !right) return null;
|
|
590
|
+
const options = [];
|
|
591
|
+
if (BROADCAST_BUILD_LEFT.has(joinType)) options.push({ joinType, buildSide: 'left', build: left, stream: right });
|
|
592
|
+
if (BROADCAST_BUILD_RIGHT.has(joinType)) options.push({ joinType, buildSide: 'right', build: right, stream: left });
|
|
593
|
+
return options.reduce ((best, o) => (best == null || shuffleBytes(o.build) < shuffleBytes(best.build) ? o : best), null);
|
|
539
594
|
}
|
|
540
595
|
|
|
541
596
|
// Max/median deviation ratio over a list of {key, value}; returns the
|
|
542
|
-
// exceeding entry's key + ratio, or null when < 3 samples or median 0.
|
|
597
|
+
// exceeding entry's key + ratio, or null when < 3 samples or median 0. `qualifies` narrows the
|
|
598
|
+
// entries the maximum is taken from (the median still covers all of them).
|
|
543
599
|
function maxMedianRatio(
|
|
544
600
|
samples ,
|
|
601
|
+
qualifies = () => true,
|
|
545
602
|
) {
|
|
546
603
|
if (samples.length < 3) return null;
|
|
547
604
|
const vals = samples.map(s => s.value).sort((a, b) => a - b);
|
|
548
605
|
const median = medianOfSorted(vals);
|
|
549
606
|
if (median <= 0) return null;
|
|
550
|
-
const
|
|
607
|
+
const pool = samples.filter(qualifies);
|
|
608
|
+
if (pool.length === 0) return null;
|
|
609
|
+
const top = pool.reduce((a, b) => (b.value > a.value ? b : a));
|
|
551
610
|
return { key: top.key, ratio: top.value / median, value: top.value };
|
|
552
611
|
}
|
|
553
612
|
|
|
613
|
+
// z a lone executor's mean must clear to stand out from `executors` chance draws at a family-wise
|
|
614
|
+
// false-flag rate of `alpha`. A normal tail obeys P(Z > z) <= exp(-z^2 / 2) / 2, so a
|
|
615
|
+
// Bonferroni share of alpha / executors gives z = sqrt(2 ln(executors / (2 alpha))): the cutoff
|
|
616
|
+
// grows with the number of executors the largest mean was picked from.
|
|
617
|
+
function chanceOutlierZ(executors , alpha ) {
|
|
618
|
+
return Math.sqrt(2 * Math.log(executors / (2 * alpha)));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// How far each executor's task durations sit above the stage's median executor, in standard errors,
|
|
622
|
+
// on the log scale: (executor mean - median of executor means) / (stage task sd / sqrt(executor
|
|
623
|
+
// tasks)). The stage sd is rebuilt from the per-executor count, log sum and M2: within-executor
|
|
624
|
+
// squared deviations plus each executor mean's distance from the grand mean. null when the stage
|
|
625
|
+
// has under two tasks, no spread, or any executor lacks finite log stats, so an unknown spread
|
|
626
|
+
// never flags an executor.
|
|
627
|
+
function executorDurationZ(execs ) {
|
|
628
|
+
const active = execs.filter(e => e.taskCount > 0);
|
|
629
|
+
let tasks = 0, logTotal = 0;
|
|
630
|
+
for (const e of active) {
|
|
631
|
+
if (!Number.isFinite(e.logDurationSum) || !Number.isFinite(e.logDurationM2)) return null;
|
|
632
|
+
tasks += e.taskCount;
|
|
633
|
+
logTotal += e.logDurationSum ;
|
|
634
|
+
}
|
|
635
|
+
if (tasks < 2) return null;
|
|
636
|
+
const grandMean = logTotal / tasks;
|
|
637
|
+
let squares = 0;
|
|
638
|
+
const means = active.map(e => (e.logDurationSum ) / e.taskCount);
|
|
639
|
+
active.forEach((e, i) => { squares += (e.logDurationM2 ) + e.taskCount * (means[i] - grandMean) ** 2; });
|
|
640
|
+
const sd = Math.sqrt(squares / (tasks - 1));
|
|
641
|
+
if (!(sd > 0) || !Number.isFinite(sd)) return null;
|
|
642
|
+
const median = medianOfSorted([...means].sort((a, b) => a - b));
|
|
643
|
+
return new Map(active.map((e, i) => [e.executorId, (means[i] - median) / (sd / Math.sqrt(e.taskCount))]));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// slowHost's taskTime dimension picks the largest of N per-executor mean task times: the more
|
|
647
|
+
// executors, the likelier the largest reaches a given ratio to the median by chance alone, whatever
|
|
648
|
+
// the hardware. The band comes from the modeled saving, so the finding states how weak the signal is.
|
|
649
|
+
// No `host` here: it is one of slowHost's id discriminators, so setting it would change the id.
|
|
650
|
+
function executorTaskTimeCaveat(
|
|
651
|
+
executorId , ratio , executorCount , taskCount ,
|
|
652
|
+
) {
|
|
653
|
+
return {
|
|
654
|
+
confidence: 'low',
|
|
655
|
+
validationRequired:
|
|
656
|
+
`One of ${executorCount} executors: the largest of ${executorCount} per-executor mean task times reaches ${Math.round(ratio * 10) / 10}x the median by chance ` +
|
|
657
|
+
`when task durations vary widely or each executor ran few tasks (${taskCount} tasks over ${executorCount} executors here). ` +
|
|
658
|
+
`Compare per-task times on executor ${executorId} in the Spark UI before acting.`,
|
|
659
|
+
executorCount, taskCount,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
554
663
|
|
|
555
664
|
|
|
556
665
|
|
|
@@ -595,7 +704,6 @@ export function computeSkewRatio(
|
|
|
595
704
|
// ratio on a few ms is noise in an hours-long run but real in a seconds-long one; a fixed-ms
|
|
596
705
|
// floor can't scale. Used by skew/straggler, gated on the same occupancy-clipped tail claim their
|
|
597
706
|
// estimate() displays as savings (tailClaimImpact).
|
|
598
|
-
// NOT SOURCED: floor percentages are our own noise floor, unvalidated.
|
|
599
707
|
// Unknown app timing never suppresses a finding; it just skips the floor gate.
|
|
600
708
|
function meetsRuntimeFloor(wasteMs , runMs , floorPct ) {
|
|
601
709
|
return runMs == null || wasteMs >= runMs * floorPct;
|
|
@@ -657,26 +765,90 @@ function tailClaimFloorMs(claim , stageId , ctx )
|
|
|
657
765
|
return tailClaimImpact(claim, stageId, ctx.impact).wallClock?.high ?? claim.wasteMs;
|
|
658
766
|
}
|
|
659
767
|
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
function
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
if (
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
if (key === 'spark.sql.adaptive.skewJoin.enabled' || key === 'spark.sql.adaptive.coalescePartitions.enabled') {
|
|
672
|
-
return major >= 3 ? 'true' : undefined;
|
|
673
|
-
}
|
|
674
|
-
return undefined;
|
|
768
|
+
// skew's duration gate: the task-time ratio over `ratioWarn` whose occupancy-clipped tail claim
|
|
769
|
+
// (the claim estimate() reports as savings, so the gate agrees with it) is at least `floorPctWarn`
|
|
770
|
+
// of the run. Null when it does not hold. straggler runs it too, to report the tails skew's gate
|
|
771
|
+
// admits whose cause is not data.
|
|
772
|
+
function skewGate(
|
|
773
|
+
stage , ctx , ratioWarn , minTasksForP95 , floorPctWarn ,
|
|
774
|
+
) {
|
|
775
|
+
const result = computeSkewRatio(stage, minTasksForP95);
|
|
776
|
+
if (result === null || result.ratio <= ratioWarn) return null;
|
|
777
|
+
const floorWasteMs = tailClaimFloorMs(skewTailClaim(stage, result.metric === 'P95/median'), stage.id, ctx);
|
|
778
|
+
return meetsRuntimeFloor(floorWasteMs, appDurationMs(ctx.app), floorPctWarn) ? result : null;
|
|
675
779
|
}
|
|
676
780
|
|
|
677
|
-
//
|
|
781
|
+
// Why a stage's slow tail is slow, from attributeTail (stage-quantiles.ts). 'data' when data volume
|
|
782
|
+
// accounts for at least `dataShareMin` of the tail's extra time, else the largest of GC, fetch wait,
|
|
783
|
+
// one host and 'unexplained'. 'unattributed' (no data volume to compare, or no tail attribution)
|
|
784
|
+
// means only the duration is known, so straggler reports it and skew does not.
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
function tailVerdict(stage , dataShareMin ) {
|
|
793
|
+
const a = stage.tailAttribution ?? null;
|
|
794
|
+
if (a === null || !(a.excessMs > 0)) return { cause: 'unattributed', sharePct: 0, attribution: a };
|
|
795
|
+
const pct = (ms ) => Math.round((ms / a.excessMs) * 100);
|
|
796
|
+
if (a.dataMs / a.excessMs >= dataShareMin) return { cause: 'data', sharePct: pct(a.dataMs), attribution: a };
|
|
797
|
+
const rest = Math.max(0, a.excessMs - a.dataMs - a.gcMs - a.fetchWaitMs - a.hostMs);
|
|
798
|
+
const causes = [['gc', a.gcMs], ['fetchWait', a.fetchWaitMs], ['host', a.hostMs],
|
|
799
|
+
[a.dataRatio === null ? 'unattributed' : 'unexplained', rest]];
|
|
800
|
+
const [cause, ms] = causes.reduce((best, c) => (c[1] > best[1] ? c : best));
|
|
801
|
+
return { cause, sharePct: cause === 'unattributed' ? 0 : pct(ms), attribution: a };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// The run's effective value of a property: the logged one, else Spark's default for the run's
|
|
805
|
+
// version (spark-conf.ts). A detector scoped to one SQL execution passes that execution's app (see
|
|
806
|
+
// queryApp) so the settings it ran with count.
|
|
678
807
|
function effectiveConf(app , key ) {
|
|
679
|
-
return (app?.
|
|
808
|
+
return effectiveSparkConf({ sparkVersion: app?.sparkVersion, properties: app?.config }, key)?.value;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// The factor and minimum Spark applies to size the executor overhead when spark.executor.memoryOverhead
|
|
812
|
+
// is unset (spark-conf.ts's executorOverheadSettings); `fallback` is the detector's default pair.
|
|
813
|
+
function defaultOverheadSettings(app , fallback ) {
|
|
814
|
+
return executorOverheadSettings({ sparkVersion: app?.sparkVersion, properties: app?.config }, fallback);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const queryAppCache = new WeakMap ();
|
|
818
|
+
|
|
819
|
+
// The app as one SQL execution saw it: its modifiedConfigs applied over the logged properties, so
|
|
820
|
+
// effectiveConf reads what `spark.conf.set` changed. The app itself when the execution modified
|
|
821
|
+
// nothing.
|
|
822
|
+
function queryApp(app , modified ) {
|
|
823
|
+
if (modified == null) return app;
|
|
824
|
+
const cached = queryAppCache.get(modified);
|
|
825
|
+
if (cached?.base === app) return cached.derived;
|
|
826
|
+
const config = overlayModifiedConfigs(app?.config, modified);
|
|
827
|
+
if (config === app?.config) return app;
|
|
828
|
+
const derived = { ...app, config };
|
|
829
|
+
queryAppCache.set(modified, { base: app, derived });
|
|
830
|
+
return derived;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// The app as the stage's SQL execution saw it; the run's app for a stage outside any SQL execution.
|
|
834
|
+
function stageApp(ctx , stage ) {
|
|
835
|
+
return queryApp(ctx.app, stage.sqlExecutionId != null ? ctx.sql.get(stage.sqlExecutionId)?.modifiedConfigs : undefined);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// When Spark relaunches a slow task under the run's effective speculation settings: once the
|
|
839
|
+
// quantile of the stage's tasks has finished, a task running over the multiplier times the median
|
|
840
|
+
// of the finished ones gets a copy (TaskSetManager.checkSpeculatableTasks). Null when either
|
|
841
|
+
// setting is neither logged nor a default the run's Spark version fixes.
|
|
842
|
+
function speculationRule(app ) {
|
|
843
|
+
const multiplier = Number.parseFloat(effectiveConf(app, 'spark.speculation.multiplier') ?? '');
|
|
844
|
+
const quantile = Number.parseFloat(effectiveConf(app, 'spark.speculation.quantile') ?? '');
|
|
845
|
+
if (!Number.isFinite(multiplier) || !Number.isFinite(quantile)) return null;
|
|
846
|
+
return `a task running over ${multiplier}x the median is relaunched once ${Math.round(quantile * 100)}% of the stage's tasks have finished`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function speculationRuleNote(app ) {
|
|
850
|
+
const rule = speculationRule(app);
|
|
851
|
+
return rule == null ? '' : ` (with this run's settings, ${rule})`;
|
|
680
852
|
}
|
|
681
853
|
|
|
682
854
|
// A setting is only a fix when the run's effective conf doesn't already have it: a run that set it,
|
|
@@ -699,6 +871,34 @@ function switchFix(on , key , suggested , recomm
|
|
|
699
871
|
return on ? { text: alreadyOn, remediation: [] } : { text: recommend, remediation: [setConf(key, suggested)] };
|
|
700
872
|
}
|
|
701
873
|
|
|
874
|
+
// Whether memoryUtilization can judge the executor heap: a measured peak and a known executor size.
|
|
875
|
+
// Without both, the stage-level low-GC note is the only memory-sizing signal there is.
|
|
876
|
+
function heapJudgementAvailable(ctx ) {
|
|
877
|
+
return executorHeapPeaks(ctx).size > 0 && (ctx.app?.resources?.executor?.memoryMB ?? 0) > 0;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const LOG_STAGE_EXECUTOR_METRICS_KEY = 'spark.eventLog.logStageExecutorMetrics';
|
|
881
|
+
const PYTHON_UDF_ARROW_KEY = 'spark.sql.execution.pythonUDF.arrow.enabled';
|
|
882
|
+
// The executor-side metrics of the Python evaluator operators. Spark's PythonSQLMetrics trait adds
|
|
883
|
+
// them in 3.4.0, the release that also adds the Arrow-optimized UDF property, so an older log never
|
|
884
|
+
// reports them and the detector skips it.
|
|
885
|
+
const PYTHON_DATA_SENT = 'data sent to Python workers';
|
|
886
|
+
const PYTHON_DATA_RETURNED = 'data returned from Python workers';
|
|
887
|
+
|
|
888
|
+
// How to cut the cost of a row-at-a-time Python UDF: the Arrow-optimized UDF property while the
|
|
889
|
+
// session lacks it, else what is left once it is on (a UDF that opted out with useArrow=False, takes no
|
|
890
|
+
// arguments, or was created before the property was set: all of them still run as BatchEvalPython).
|
|
891
|
+
function pythonUdfArrowFix(app ) {
|
|
892
|
+
return switchFix(
|
|
893
|
+
loggedAs(app, PYTHON_UDF_ARROW_KEY, true), PYTHON_UDF_ARROW_KEY, true,
|
|
894
|
+
`set ${PYTHON_UDF_ARROW_KEY}=true to ship rows in Arrow batches, or rewrite the UDF as a pandas UDF.`,
|
|
895
|
+
`Arrow-optimized Python UDFs are already on for the session, so this UDF may have opted out with useArrow=False, take no arguments, or have been created before the property was set: check which, or rewrite it as a pandas UDF.`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// A tail reading at least this many times the median task's data is not described as reading no more.
|
|
900
|
+
const MORE_DATA_RATIO = 2;
|
|
901
|
+
|
|
702
902
|
const SKEW_KEY_REMEDY = 'salt the key or repartition on a better key';
|
|
703
903
|
|
|
704
904
|
// What a stage reads, by the dominant side: a stage that reads a shuffle and mostly input files is
|
|
@@ -717,24 +917,22 @@ function predatesAqeSkewJoin(app ) {
|
|
|
717
917
|
// What a skewed stage reads, which decides whether AQE skew-join handling can act on it. It splits
|
|
718
918
|
// skewed partitions on the shuffle-read side of a sort-merge or shuffled-hash join, so only a stage
|
|
719
919
|
// that reads a shuffle, in a SQL execution whose plan has such a join, is 'shuffleJoin'. A plan does
|
|
720
|
-
// not say which stage runs which join
|
|
721
|
-
// matched per execution. 'inputScan' reads mostly files (its tasks differ in input size);
|
|
920
|
+
// not say which stage runs which join, so the join is matched per execution. 'inputScan' reads mostly files (its tasks differ in input size);
|
|
722
921
|
// 'other' is any stage the plan cannot tie to a join (an aggregation's shuffle, no plan, no input),
|
|
723
922
|
// and any stage of a run on Spark before 3.0 (skewFix).
|
|
724
923
|
function skewOrigin(stage , ctx , shuffleEvidence ) {
|
|
725
924
|
if (shuffleEvidence || (stage.shuffleReadBytes > 0 && stage.shuffleReadBytes >= stage.inputBytes)) {
|
|
726
925
|
let hasJoin = false;
|
|
727
926
|
const plan = stage.sqlExecutionId != null ? ctx.sql.get(stage.sqlExecutionId)?.planTree : null;
|
|
728
|
-
walkPlanTree(plan, (node) => { if (
|
|
927
|
+
walkPlanTree(plan, (node) => { if (isSkewJoinNode(node.name)) hasJoin = true; });
|
|
729
928
|
return hasJoin ? 'shuffleJoin' : 'other';
|
|
730
929
|
}
|
|
731
930
|
return stage.inputBytes > 0 ? 'inputScan' : 'other';
|
|
732
931
|
}
|
|
733
|
-
const SKEW_JOIN_NODES = new Set(['SortMergeJoin', 'ShuffledHashJoin']);
|
|
734
932
|
|
|
735
933
|
// The skew finding's fix for the stage's origin. Join-driven skew gets AQE skew-join handling,
|
|
736
934
|
// unless the run's effective conf already has it; uneven input gets the file-size remedy.
|
|
737
|
-
function skewFix(stage , ctx , shuffleEvidence = false)
|
|
935
|
+
function skewFix(stage , ctx , shuffleEvidence = false, tail ) {
|
|
738
936
|
const origin = skewOrigin(stage, ctx, shuffleEvidence);
|
|
739
937
|
if (origin === 'inputScan') {
|
|
740
938
|
return {
|
|
@@ -746,7 +944,22 @@ function skewFix(stage , ctx , shuffleEvidence = false
|
|
|
746
944
|
if (origin === 'other') return { origin, text: SKEW_KEY_REMEDY, remediation: [codeFix(SKEW_KEY_REMEDY)] };
|
|
747
945
|
// Before Spark 3.0 there is no AQE skew-join handling, so the stage gets the generic advice.
|
|
748
946
|
if (predatesAqeSkewJoin(ctx.app)) return { origin: 'other', text: SKEW_KEY_REMEDY, remediation: [codeFix(SKEW_KEY_REMEDY)] };
|
|
749
|
-
|
|
947
|
+
const app = stageApp(ctx, stage);
|
|
948
|
+
const fix = skewJoinFix(app);
|
|
949
|
+
// A tail the data volume explains (input plus shuffle read, in bytes or records) is data the
|
|
950
|
+
// diagnosis must not call even or cured from shuffle-read bytes alone.
|
|
951
|
+
const tailData = tail?.cause === 'data' ? { ratio: tail.attribution?.dataRatio ?? null } : undefined;
|
|
952
|
+
// With skew-join handling on, the final plan and conf say why it did or did not act on this stage.
|
|
953
|
+
const handlingOn = !loggedAs(app, 'spark.sql.adaptive.enabled', false) && loggedAs(app, 'spark.sql.adaptive.skewJoin.enabled', true);
|
|
954
|
+
const diagnosis = handlingOn && stage.sqlExecutionId != null
|
|
955
|
+
? diagnoseJoinSkew({
|
|
956
|
+
plan: ctx.sql.get(stage.sqlExecutionId)?.planTree, stageId: stage.id,
|
|
957
|
+
readMax: stage.shuffleReadMax, readP50: stage.shuffleReadP50, stageShuffleReadBytes: stage.shuffleReadBytes,
|
|
958
|
+
...(tailData ? { tailData } : {}),
|
|
959
|
+
conf: (key) => effectiveConf(app, key), sparkVersion: app?.sparkVersion, keyRemedy: SKEW_KEY_REMEDY,
|
|
960
|
+
})
|
|
961
|
+
: null;
|
|
962
|
+
return diagnosis == null ? { origin, ...fix } : { origin, aqeSkew: diagnosis.case, text: diagnosis.text, remediation: diagnosis.remediation };
|
|
750
963
|
}
|
|
751
964
|
|
|
752
965
|
function skewJoinFix(app ) {
|
|
@@ -780,7 +993,7 @@ function idleCapacityFix(app , recommend , alreadyOnLead
|
|
|
780
993
|
const fix = dynamicAllocationFix(app, recommend, alreadyOnLead);
|
|
781
994
|
// dynamicAllocationFix's remediation is empty exactly when dynamic allocation is already on.
|
|
782
995
|
if (fix.remediation.length > 0) return { ...fix, remediation: [...fix.remediation, decreaseConf('spark.executor.instances')] };
|
|
783
|
-
const holdsFloor = Number.parseInt(app
|
|
996
|
+
const holdsFloor = Number.parseInt(effectiveConf(app, 'spark.dynamicAllocation.minExecutors') ?? '', 10) > 0;
|
|
784
997
|
return holdsFloor
|
|
785
998
|
? { text: `${alreadyOnLead} by lowering spark.dynamicAllocation.maxExecutors and spark.dynamicAllocation.minExecutors`,
|
|
786
999
|
remediation: [decreaseConf('spark.dynamicAllocation.maxExecutors'), decreaseConf('spark.dynamicAllocation.minExecutors')] }
|
|
@@ -794,31 +1007,129 @@ function effectiveShufflePartitions(app ) {
|
|
|
794
1007
|
return value != null && /^\d+$/.test(value) ? Number(value) : null;
|
|
795
1008
|
}
|
|
796
1009
|
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1010
|
+
// The partition count and property that sized a shuffle-reading stage's tasks. AQE only rewrites the
|
|
1011
|
+
// count of a stage inside a SQL execution. With coalescing on, `initialPartitionNum` replaces
|
|
1012
|
+
// spark.sql.shuffle.partitions as the starting count when it is set
|
|
1013
|
+
// (CoalesceShufflePartitions.coalesceShufflePartitions at v3.5.0), so it is the lever then.
|
|
1014
|
+
const INITIAL_PARTITION_NUM_KEY = 'spark.sql.adaptive.coalescePartitions.initialPartitionNum';
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
function partitionBasis(stage , ctx ) {
|
|
1032
|
+
const app = stageApp(ctx, stage);
|
|
1033
|
+
const aqe = stage.sqlExecutionId != null && loggedAs(app, 'spark.sql.adaptive.enabled', true);
|
|
1034
|
+
const coalesce = aqe && loggedAs(app, 'spark.sql.adaptive.coalescePartitions.enabled', true);
|
|
1035
|
+
const initial = coalesce ? effectiveConf(app, INITIAL_PARTITION_NUM_KEY) : undefined;
|
|
1036
|
+
const plan = stage.sqlExecutionId != null ? ctx.sql.get(stage.sqlExecutionId)?.planTree : null;
|
|
1037
|
+
const skewSplit = aqe ? planShowsSkewSplit(plan) : null;
|
|
1038
|
+
const explicitRepartition = planHasExplicitRepartition(plan);
|
|
1039
|
+
if (initial != null && /^\d+$/.test(initial)) return { count: Number(initial), key: INITIAL_PARTITION_NUM_KEY, aqe, coalesce, skewSplit, explicitRepartition };
|
|
1040
|
+
return { count: effectiveShufflePartitions(app), key: 'spark.sql.shuffle.partitions', aqe, coalesce, skewSplit, explicitRepartition };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Whether a plan holds an exchange the job asked for (`repartition(n)`, `repartition(col)` or a
|
|
1044
|
+
// rebalance hint), which is what makes a task count different from the configured one the stage's own.
|
|
1045
|
+
function planHasExplicitRepartition(plan ) {
|
|
1046
|
+
let found = false;
|
|
1047
|
+
walkPlanTree(plan, (node) => { if (/\b(REPARTITION_BY_NUM|REPARTITION_BY_COL|REBALANCE_PARTITIONS_BY_NONE|REBALANCE_PARTITIONS_BY_COL)\b/.test(node.detail ?? '')) found = true; });
|
|
1048
|
+
return found;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// How the stage's task count relates to the configured one. 'matches': the configured count sized it
|
|
1052
|
+
// (or the count is unknown). 'coalesced': AQE merged the partitions into fewer tasks. 'adjusted': more
|
|
1053
|
+
// tasks than configured under AQE, which a skew-join split can produce and several shuffle reads in
|
|
1054
|
+
// one execution can obscure: the stage's own repartition(n) is blamed only when the plan holds one.
|
|
1055
|
+
// 'own': a different count with no AQE to rewrite it, or with a repartition(n) in the plan, which is
|
|
1056
|
+
// a repartition(n) or an RDD operation that fixed its own count.
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
function countRelation(stage , basis ) {
|
|
1060
|
+
if (basis.count == null || stage.taskCount === basis.count) return 'matches';
|
|
1061
|
+
if (basis.coalesce && stage.taskCount < basis.count) return 'coalesced';
|
|
1062
|
+
if (basis.aqe && stage.taskCount > basis.count && (basis.skewSplit !== false || !basis.explicitRepartition)) return 'adjusted';
|
|
1063
|
+
return 'own';
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Whether partition-count advice fits a shuffle-reading stage. The count that brings each shuffle
|
|
1067
|
+
// partition to the ideal size is per stage; the property is job-wide. 'sufficient': the tasks are
|
|
1068
|
+
// already at or under the ideal size. 'aqeCoalesced' and 'ownPartitioning' follow countRelation
|
|
1069
|
+
// ('ownPartitioning' also when the property already gives at least the count needed). Otherwise
|
|
1070
|
+
// the property limits the stage: 'raise'.
|
|
1071
|
+
function shufflePartitionCase(stage , ctx ) {
|
|
1072
|
+
const basis = partitionBasis(stage, ctx);
|
|
1073
|
+
const { count, key } = basis;
|
|
806
1074
|
const needed = Math.ceil(stage.shuffleReadBytes / IDEAL_BYTES_PER_PARTITION_TASK);
|
|
807
1075
|
const tasks = Math.max(1, stage.taskCount);
|
|
1076
|
+
const relation = countRelation(stage, basis);
|
|
808
1077
|
let partitions = 'raise';
|
|
809
1078
|
if (stage.shuffleReadBytes / tasks <= IDEAL_BYTES_PER_PARTITION_TASK) partitions = 'sufficient';
|
|
810
|
-
else if (
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
return { partitions, count, needed };
|
|
1079
|
+
else if (relation === 'coalesced') partitions = 'aqeCoalesced';
|
|
1080
|
+
else if (count != null && count >= needed && relation !== 'adjusted') partitions = 'ownPartitioning';
|
|
1081
|
+
return { partitions, count, key, needed };
|
|
814
1082
|
}
|
|
815
1083
|
|
|
816
1084
|
const ADVISORY_PARTITION_SIZE_KEY = 'spark.sql.adaptive.advisoryPartitionSizeInBytes';
|
|
1085
|
+
const PARALLELISM_FIRST_KEY = 'spark.sql.adaptive.coalescePartitions.parallelismFirst';
|
|
1086
|
+
|
|
1087
|
+
// What sized a shuffle-reading stage's tasks, for advice that moves the partition count: the
|
|
1088
|
+
// countRelation outcomes as levers ('matches' is 'property'; 'adjusted' is 'aqeAdjusted', where the
|
|
1089
|
+
// advice stays on the property). Under 'aqeCoalesced' the property does not set the count: AQE
|
|
1090
|
+
// sizes the merged tasks from the advisory size and, with parallelismFirst (Spark's default), from
|
|
1091
|
+
// defaultParallelism too (ShufflePartitionsUtil.coalescePartitions and CoalesceShufflePartitions
|
|
1092
|
+
// at v3.5.0).
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
function partitionLever(stage , ctx ) {
|
|
1096
|
+
const basis = partitionBasis(stage, ctx);
|
|
1097
|
+
const relation = countRelation(stage, basis);
|
|
1098
|
+
const lever = relation === 'matches' ? 'property'
|
|
1099
|
+
: relation === 'coalesced' ? 'aqeCoalesced'
|
|
1100
|
+
: relation === 'adjusted' ? 'aqeAdjusted' : 'ownPartitioning';
|
|
1101
|
+
return { lever, count: basis.count, key: basis.key, explicitRepartition: basis.explicitRepartition };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// What follows advice on the property when the stage ran more tasks than configured under AQE.
|
|
1105
|
+
function aqeAdjustedNote(stage , count , explicitRepartition ) {
|
|
1106
|
+
return explicitRepartition
|
|
1107
|
+
? ` (this stage ran ${stage.taskCount} tasks against ${count} configured, and AQE skew-join splits add tasks, so that is not necessarily its own repartition(n))`
|
|
1108
|
+
: ` (this stage ran ${stage.taskCount} tasks against ${count} configured, and the plan shows no repartition behind the difference)`;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Whether parallelismFirst is on for the run: logged, else Spark's default from 3.2.0, where it
|
|
1112
|
+
// exists. A log that records no Spark version is read as current.
|
|
1113
|
+
function parallelismFirstOn(app ) {
|
|
1114
|
+
const effective = effectiveConf(app, PARALLELISM_FIRST_KEY);
|
|
1115
|
+
if (effective != null) return effective.toLowerCase() === 'true';
|
|
1116
|
+
const version = /^(\d+)\.(\d+)/.exec(app?.sparkVersion ?? '');
|
|
1117
|
+
return version == null || Number(version[1]) > 3 || Number(version[2]) >= 2;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// The conf that makes AQE's merged tasks larger. With parallelismFirst on, AQE targets
|
|
1121
|
+
// min(total / defaultParallelism, advisory size), so the advisory size alone merges nothing for
|
|
1122
|
+
// small tasks: turn parallelismFirst off first. With it off, the advisory size is the lever.
|
|
1123
|
+
function aqeCoalesceLargerFix(app ) {
|
|
1124
|
+
return parallelismFirstOn(app)
|
|
1125
|
+
? { keys: `setting ${PARALLELISM_FIRST_KEY}=false (so AQE targets the advisory size, not defaultParallelism tasks) and then raising ${ADVISORY_PARTITION_SIZE_KEY}`, remediation: [setConf(PARALLELISM_FIRST_KEY, false), increaseConf(ADVISORY_PARTITION_SIZE_KEY)] }
|
|
1126
|
+
: { keys: `raising ${ADVISORY_PARTITION_SIZE_KEY}`, remediation: [increaseConf(ADVISORY_PARTITION_SIZE_KEY)] };
|
|
1127
|
+
}
|
|
817
1128
|
|
|
818
1129
|
// lowShuffleParallelism's fix. Unlike the shuffle finding it fires on stages whose tasks are larger
|
|
819
1130
|
// than the ideal size, so its cases are 'raise', 'aqeCoalesced' and 'ownPartitioning'.
|
|
820
|
-
function lowShuffleParallelismFix(stage ,
|
|
821
|
-
const { partitions, count, needed } = shufflePartitionCase(stage,
|
|
1131
|
+
function lowShuffleParallelismFix(stage , ctx ) {
|
|
1132
|
+
const { partitions, count, key, needed } = shufflePartitionCase(stage, ctx);
|
|
822
1133
|
if (partitions === 'aqeCoalesced') {
|
|
823
1134
|
return {
|
|
824
1135
|
partitions,
|
|
@@ -829,36 +1140,104 @@ function lowShuffleParallelismFix(stage , app )
|
|
|
829
1140
|
if (partitions === 'ownPartitioning') {
|
|
830
1141
|
return {
|
|
831
1142
|
partitions,
|
|
832
|
-
text:
|
|
1143
|
+
text: `${key} is already ${count}, so raise this stage's own partition count (its repartition(n) or RDD parallelism) so each partition is smaller`,
|
|
833
1144
|
remediation: [],
|
|
834
1145
|
};
|
|
835
1146
|
}
|
|
836
1147
|
return {
|
|
837
1148
|
partitions,
|
|
838
|
-
text:
|
|
839
|
-
remediation: [increaseConf(
|
|
1149
|
+
text: `raise ${key} so each partition is smaller`,
|
|
1150
|
+
remediation: [increaseConf(key, count == null ? null : needed)],
|
|
840
1151
|
};
|
|
841
1152
|
}
|
|
842
1153
|
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1154
|
+
// tinyTask's fix on a stage that reads a shuffle: fewer, larger tasks, through whatever sized this
|
|
1155
|
+
// stage. Lowering spark.sql.shuffle.partitions merges nothing AQE kept for parallelism, and does
|
|
1156
|
+
// not touch a repartition(n).
|
|
1157
|
+
function tinyShuffleFix(stage , ctx , coalesceTo ) {
|
|
1158
|
+
const { lever, count, key, explicitRepartition } = partitionLever(stage, ctx);
|
|
1159
|
+
if (lever === 'aqeCoalesced') {
|
|
1160
|
+
const larger = aqeCoalesceLargerFix(stageApp(ctx, stage));
|
|
1161
|
+
return {
|
|
1162
|
+
text: `${larger.keys}, or using .coalesce(${coalesceTo}): AQE already coalesced the ${count} configured shuffle partitions into ${stage.taskCount} tasks, so lowering the shuffle partition count will not merge them`,
|
|
1163
|
+
remediation: larger.remediation,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
if (lever === 'ownPartitioning') {
|
|
1167
|
+
const hint = `lowering the repartition(n) or RDD partition count that sized this stage, or using .coalesce(${coalesceTo})`;
|
|
1168
|
+
return { text: `${hint} (the configured shuffle partition count is ${count} but this stage ran ${stage.taskCount} tasks)`, remediation: [codeFix(hint)] };
|
|
1169
|
+
}
|
|
1170
|
+
const note = lever === 'aqeAdjusted' ? aqeAdjustedNote(stage, count, explicitRepartition) : '';
|
|
1171
|
+
return { text: `lowering ${key} or using .coalesce(${coalesceTo})${note}`, remediation: [decreaseConf(key)] };
|
|
849
1172
|
}
|
|
850
1173
|
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
function
|
|
854
|
-
const
|
|
1174
|
+
// spill's fix on a stage that reads a shuffle and spills for volume: smaller partitions or more
|
|
1175
|
+
// memory. Raising spark.sql.shuffle.partitions only helps while the property sized the stage.
|
|
1176
|
+
function spillShuffleFix(stage , ctx ) {
|
|
1177
|
+
const memory = increaseConf('spark.executor.memory');
|
|
1178
|
+
const { lever, count, key, explicitRepartition } = partitionLever(stage, ctx);
|
|
1179
|
+
if (lever === 'aqeCoalesced') {
|
|
1180
|
+
return {
|
|
1181
|
+
text: `AQE coalesced the ${count} configured shuffle partitions into ${stage.taskCount} tasks: lower ${ADVISORY_PARTITION_SIZE_KEY} so each partition is smaller, or increase executor memory`,
|
|
1182
|
+
remediation: [decreaseConf(ADVISORY_PARTITION_SIZE_KEY), memory],
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
if (lever === 'ownPartitioning') {
|
|
1186
|
+
const hint = "raise this stage's own partition count (its repartition(n) or RDD parallelism) so each partition is smaller";
|
|
1187
|
+
return { text: `the configured shuffle partition count is ${count} but this stage ran ${stage.taskCount} tasks, so ${hint}, or increase executor memory`, remediation: [codeFix(hint), memory] };
|
|
1188
|
+
}
|
|
1189
|
+
const note = lever === 'aqeAdjusted' ? aqeAdjustedNote(stage, count, explicitRepartition) : '';
|
|
1190
|
+
return { text: `raise ${key} or increase executor memory${note}`, remediation: [increaseConf(key), memory] };
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// stageSlowness's fix on a stage that reads a shuffle: more tasks. spark.default.parallelism sizes
|
|
1194
|
+
// RDD shuffles only (the DataFrame shuffle reads spark.sql.shuffle.partitions), so it is not offered.
|
|
1195
|
+
function slowShuffleFix(stage , ctx ) {
|
|
1196
|
+
const { lever, count, key, explicitRepartition } = partitionLever(stage, ctx);
|
|
1197
|
+
if (lever === 'aqeCoalesced') {
|
|
1198
|
+
return {
|
|
1199
|
+
text: `AQE coalesced the ${count} configured shuffle partitions into ${stage.taskCount} tasks, so lower ${ADVISORY_PARTITION_SIZE_KEY} to get more of them`,
|
|
1200
|
+
remediation: [decreaseConf(ADVISORY_PARTITION_SIZE_KEY)],
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
if (lever === 'ownPartitioning') {
|
|
1204
|
+
const hint = "raise this stage's own partition count (its repartition(n) or RDD parallelism)";
|
|
1205
|
+
return { text: `the configured shuffle partition count is ${count} but this stage ran ${stage.taskCount} tasks, so ${hint}`, remediation: [codeFix(hint)] };
|
|
1206
|
+
}
|
|
1207
|
+
const note = lever === 'aqeAdjusted' ? aqeAdjustedNote(stage, count, explicitRepartition) : '';
|
|
1208
|
+
return { text: `raise ${key}${note}`, remediation: [increaseConf(key)] };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Spark's default spark.dynamicAllocation.maxExecutors: no upper bound.
|
|
1212
|
+
const UNBOUNDED_EXECUTORS = 2147483647;
|
|
1213
|
+
|
|
1214
|
+
const BROADCAST_THRESHOLD_KEY = 'spark.sql.autoBroadcastJoinThreshold';
|
|
1215
|
+
const ADAPTIVE_BROADCAST_THRESHOLD_KEY = 'spark.sql.adaptive.autoBroadcastJoinThreshold';
|
|
1216
|
+
// AQE keeps a join side out of a broadcast when too few of its shuffle partitions are non-empty
|
|
1217
|
+
// (DynamicJoinSelection at v3.5.0).
|
|
1218
|
+
const NON_EMPTY_PARTITION_RATIO_KEY = 'spark.sql.adaptive.nonEmptyPartitionRatioForBroadcastJoin';
|
|
1219
|
+
|
|
1220
|
+
// The property that bounds a broadcast: with AQE, runtime sizes are judged against the adaptive
|
|
1221
|
+
// threshold when it is set (JoinSelectionHelper.canBroadcastBySize, apache/spark v3.5.0), and
|
|
1222
|
+
// against spark.sql.autoBroadcastJoinThreshold otherwise.
|
|
1223
|
+
function broadcastThresholdKey(app , aqe ) {
|
|
1224
|
+
return aqe && effectiveConf(app, ADAPTIVE_BROADCAST_THRESHOLD_KEY) !== undefined ? ADAPTIVE_BROADCAST_THRESHOLD_KEY : BROADCAST_THRESHOLD_KEY;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// The run's effective value of a broadcast threshold property in bytes (logged, else Spark's 10 MiB
|
|
1228
|
+
// default for the static one); negative when auto-broadcast is disabled, null when the value is
|
|
1229
|
+
// not a size or the property has none.
|
|
1230
|
+
function effectiveBroadcastThreshold(app , key ) {
|
|
1231
|
+
const raw = effectiveConf(app, key);
|
|
855
1232
|
if (raw != null && /^-\d+$/.test(raw)) return -1;
|
|
856
1233
|
return parseSparkBytes(raw);
|
|
857
1234
|
}
|
|
858
1235
|
|
|
859
|
-
// No dynamic-allocation property has an effect on a run
|
|
1236
|
+
// No dynamic-allocation property has an effect on a run that has it off: Spark's default is off, so
|
|
1237
|
+
// an unset property counts as off, as it does for idleCapacityFix.
|
|
860
1238
|
function dynamicAllocationOff(app ) {
|
|
861
|
-
|
|
1239
|
+
if (app?.resources?.dynamicAllocationEnabled != null) return !app.resources.dynamicAllocationEnabled;
|
|
1240
|
+
return !loggedAs(app, 'spark.dynamicAllocation.enabled', true);
|
|
862
1241
|
}
|
|
863
1242
|
|
|
864
1243
|
// Shared by cacheUtilization's two variants, worded per storage source: neither is a runtime
|
|
@@ -1110,8 +1489,7 @@ function gcConfidence(
|
|
|
1110
1489
|
// warnFloor gates the finding, so a value just past it is the weakest evidence this detector can
|
|
1111
1490
|
// produce. highFloor is critPct: straggler's own second (speculative-share) tier, 2x warnPct
|
|
1112
1491
|
// (0.10 -> 0.20); reused as the high-confidence bar for the stragglerShare path too since that
|
|
1113
|
-
// metric has no
|
|
1114
|
-
// on the straggler detector) but is the same 0-1 task-share magnitude.
|
|
1492
|
+
// metric has no critical tier of its own but is the same 0-1 task-share magnitude.
|
|
1115
1493
|
function stragglerConfidence(shareValue , warnFloor , highFloor ) {
|
|
1116
1494
|
if (shareValue < warnFloor * 1.5) return 'low';
|
|
1117
1495
|
if (shareValue >= highFloor) return 'high';
|
|
@@ -1182,29 +1560,156 @@ function gcValidation(minRunTimeMs ) {
|
|
|
1182
1560
|
|
|
1183
1561
|
const INCOMPLETE_RUN_RECOMMENDATION = 'This event log never recorded an ApplicationEnd event: the capture stopped before the run finished (a job still running, a rotated log, or a cut-short capture), so every figure on this board covers only what was captured.';
|
|
1184
1562
|
|
|
1563
|
+
// straggler's own gate: tasks over 4x P50 are more than `shareWarn` of the stage (`shareWarnAtFloor`
|
|
1564
|
+
// when the occupancy-clipped tail clears the runtime floor), or a speculative task ran. Null when it
|
|
1565
|
+
// does not hold, else what the finding reports.
|
|
1566
|
+
function stragglerGate(stage , ctx , thresholds
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
) {
|
|
1570
|
+
if (stage.taskCount < thresholds.minTasks) return null;
|
|
1571
|
+
const runMs = appDurationMs(ctx.app);
|
|
1572
|
+
if (stageBelowRuntimeFloor(stage, ctx, thresholds.floorPctWarn)) return null;
|
|
1573
|
+
const stragglerShare = (stage.stragglerCount ?? 0) / stage.taskCount;
|
|
1574
|
+
const useSpeculative = (stage.speculativeTasks ?? 0) > 0;
|
|
1575
|
+
if (!useSpeculative && stragglerShare <= thresholds.shareWarnAtFloor) return null;
|
|
1576
|
+
const speculativeShare = useSpeculative ? stage.speculativeTasks / stage.taskCount : 0;
|
|
1577
|
+
// The claim estimate() reports as savings: a high straggler/speculative share on a stage
|
|
1578
|
+
// whose tasks barely vary models near-zero savings, so it must not outrank 'info'.
|
|
1579
|
+
const floorWasteMs = tailClaimFloorMs(stragglerTailClaim(stage), stage.id, ctx);
|
|
1580
|
+
const meetsWarnFloor = meetsRuntimeFloor(floorWasteMs, runMs, thresholds.floorPctWarn);
|
|
1581
|
+
const meetsCritFloor = meetsRuntimeFloor(floorWasteMs, runMs, thresholds.floorPctCrit);
|
|
1582
|
+
// The lower gate needs positive evidence the tail matters: meetsRuntimeFloor passes by
|
|
1583
|
+
// default when the app's duration is unknown (an incomplete run), which isn't that.
|
|
1584
|
+
const stragglerShareFires = stragglerShare > thresholds.shareWarn
|
|
1585
|
+
|| (stragglerShare > thresholds.shareWarnAtFloor && runMs != null && meetsWarnFloor);
|
|
1586
|
+
if (!useSpeculative && !stragglerShareFires) return null;
|
|
1587
|
+
const speculativeTier = speculativeShare >= thresholds.critPct && meetsCritFloor ? 'critical'
|
|
1588
|
+
: speculativeShare >= thresholds.warnPct && meetsWarnFloor ? 'warning' : 'info';
|
|
1589
|
+
// A straggler share has only a warning tier (threshold-tables.md, Straggler row).
|
|
1590
|
+
const stragglerTier = stragglerShareFires && meetsWarnFloor ? 'warning' : 'info';
|
|
1591
|
+
// Report whichever signal actually drove the finding, not just whether speculation was on:
|
|
1592
|
+
// a high stragglerShare with few speculative retries must not be reported as a low-value
|
|
1593
|
+
// speculativeTasks count. Ties keep the speculative-driven default.
|
|
1594
|
+
const useSpeculativeMetric = useSpeculative && !(IMPACT_BAND_ORDER[stragglerTier] < IMPACT_BAND_ORDER[speculativeTier]);
|
|
1595
|
+
const value = useSpeculativeMetric ? stage.speculativeTasks : Math.round(stragglerShare * 100);
|
|
1596
|
+
return {
|
|
1597
|
+
useSpeculativeMetric, value,
|
|
1598
|
+
detail: useSpeculativeMetric
|
|
1599
|
+
? `${value} speculative attempt${value === 1 ? '' : 's'} discarded${speculationRuleNote(ctx.app)}`
|
|
1600
|
+
: `${value}% of tasks ran over ${STRAGGLER_FACTOR}× the median`,
|
|
1601
|
+
confidence: useSpeculativeMetric
|
|
1602
|
+
? stragglerConfidence(speculativeShare, thresholds.warnPct, thresholds.critPct)
|
|
1603
|
+
: stragglerConfidence(stragglerShare, thresholds.shareWarn, thresholds.critPct),
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// The straggler finding's evidence, recommendation and fix for the tail's cause. Without a measured
|
|
1608
|
+
// cause there is nothing to fix yet, only the causes to check.
|
|
1609
|
+
function stragglerAdvice(stage , ctx , tail , detail )
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
{
|
|
1614
|
+
const a = tail.attribution;
|
|
1615
|
+
const cpuPct = a?.cpuPct != null ? Math.round(a.cpuPct) : undefined;
|
|
1616
|
+
const evidence = { cause: tail.cause, ...(tail.cause === 'unattributed' ? {} : { causeSharePct: tail.sharePct }), ...(cpuPct !== undefined ? { cpuPct } : {}) };
|
|
1617
|
+
const share = `${tail.sharePct}% of the slow tasks' extra time`;
|
|
1618
|
+
switch (tail.cause) {
|
|
1619
|
+
case 'data': {
|
|
1620
|
+
// skew reports a tail its gate admits; this is one only the straggler gate does.
|
|
1621
|
+
const fix = skewFix(stage, ctx, false, tail);
|
|
1622
|
+
const ratio = a?.dataRatio != null ? Math.round(a.dataRatio * 10) / 10 : null;
|
|
1623
|
+
return {
|
|
1624
|
+
evidence: { ...evidence, origin: fix.origin, aqeSkew: fix.aqeSkew }, remediation: fix.remediation,
|
|
1625
|
+
recommendation: `${detail}: data volume accounts for ${share} (${ratio !== null ? `the slow tasks read a median ${ratio}× the data of the median task` : 'the median task read almost nothing'}): ${fix.text}.`,
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
case 'gc':
|
|
1629
|
+
return {
|
|
1630
|
+
evidence, remediation: [increaseConf('spark.executor.memory')],
|
|
1631
|
+
recommendation: `${detail}: GC accounts for ${share}: reduce object creation, use primitive types, avoid UDFs, increase executor memory.`,
|
|
1632
|
+
};
|
|
1633
|
+
case 'fetchWait':
|
|
1634
|
+
return {
|
|
1635
|
+
evidence, remediation: undefined,
|
|
1636
|
+
recommendation: `${detail}: waiting on shuffle fetches accounts for ${share}: look for a slow or overloaded node serving shuffle blocks, executors lost mid-stage, or reducers fetching many small blocks.`,
|
|
1637
|
+
};
|
|
1638
|
+
case 'host': {
|
|
1639
|
+
const speculation = switchFix(loggedAs(ctx.app, 'spark.speculation', true), 'spark.speculation', true,
|
|
1640
|
+
'check what it was running, and consider enabling spark.speculation to relaunch a lagging task automatically',
|
|
1641
|
+
'check what it was running; speculation is already on, so a lagging task there is already relaunched');
|
|
1642
|
+
return {
|
|
1643
|
+
evidence: { ...evidence, host: a .host , hostTasks: a .hostTasks }, remediation: speculation.remediation,
|
|
1644
|
+
recommendation: `${detail}: ${a .hostTasks} of the ${a .tasks} slow tasks ran on ${a .host}, which accounts for ${share}: ${speculation.text}.`,
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
case 'unexplained': {
|
|
1648
|
+
const cpu = cpuPct === undefined ? '' : cpuPct < 50
|
|
1649
|
+
? ` They used ${cpuPct}% of their run time on CPU, so they mostly waited (on storage, a remote service or a lock).`
|
|
1650
|
+
: ` They used ${cpuPct}% of their run time on CPU, so the work itself is slow.`;
|
|
1651
|
+
// A tail that read clearly more data is not "no more data" even when run time did not scale
|
|
1652
|
+
// with it; where data is the largest share anyway, the data advice stays.
|
|
1653
|
+
const dataRatio = a?.dataRatio ?? 0;
|
|
1654
|
+
if (a != null && dataRatio >= MORE_DATA_RATIO) {
|
|
1655
|
+
const rounded = Math.round(dataRatio * 10) / 10;
|
|
1656
|
+
const dataShare = Math.round((a.dataMs / a.excessMs) * 100);
|
|
1657
|
+
const dataLargest = a.dataMs >= a.excessMs - a.dataMs - a.gcMs - a.fetchWaitMs - a.hostMs;
|
|
1658
|
+
const lead = `${detail}: the slow tasks read a median ${rounded}× the data of the median task, and data volume accounts for ${dataShare}% of their extra time`;
|
|
1659
|
+
if (dataLargest) {
|
|
1660
|
+
const fix = skewFix(stage, ctx, false, tail);
|
|
1661
|
+
return {
|
|
1662
|
+
evidence: { ...evidence, origin: fix.origin, aqeSkew: fix.aqeSkew }, remediation: fix.remediation,
|
|
1663
|
+
recommendation: `${lead}, more than anything else the log attributes (not a majority): ${fix.text}.`,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
return {
|
|
1667
|
+
evidence, remediation: undefined,
|
|
1668
|
+
recommendation: `${lead}, so run time did not scale with it, and GC, shuffle fetch wait and one slow host do not account for the rest.${cpu} Look at per-record cost (UDFs, regular expressions, a call out per row) as well as the data they read.`,
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
return {
|
|
1672
|
+
evidence, remediation: undefined,
|
|
1673
|
+
recommendation: `${detail}: the slow tasks read no more data than the median task, and GC, shuffle fetch wait and one slow host do not account for their time.${cpu} Look at per-record cost (UDFs, regular expressions, a call out per row).`,
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
default:
|
|
1677
|
+
return {
|
|
1678
|
+
evidence, remediation: undefined,
|
|
1679
|
+
recommendation: `${detail}: nothing in the log attributes the slow tasks to data volume, GC, shuffle fetch wait or one host, so check their input sizes, GC time and hosts before choosing a fix.`,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1185
1684
|
export const DETECTORS = [
|
|
1186
1685
|
defineStageDetector({
|
|
1187
1686
|
type: 'skew', order: 30, fixEffort: 'code', version: 1,
|
|
1188
1687
|
emits: ['skew'],
|
|
1189
1688
|
docAnchor: '#bottleneck-skew',
|
|
1190
|
-
|
|
1689
|
+
// dataShareMin: the share of the slow tasks' extra time that run time proportional to their data
|
|
1690
|
+
// volume must account for before the tail is data skew; straggler judges a tail with it too.
|
|
1691
|
+
thresholds: { ratioWarn: 3, minTasksForP95: 20, dataShareMin: 0.5, floorPctWarn: IMPACT_FLOOR_PCT_WARN },
|
|
1191
1692
|
detect(stage, ctx, thresholds) {
|
|
1192
|
-
const
|
|
1193
|
-
if (
|
|
1194
|
-
const { ratio, metric } =
|
|
1195
|
-
|
|
1196
|
-
//
|
|
1197
|
-
const
|
|
1198
|
-
if (
|
|
1693
|
+
const gate = skewGate(stage, ctx, thresholds.ratioWarn, thresholds.minTasksForP95, thresholds.floorPctWarn);
|
|
1694
|
+
if (gate === null) return null;
|
|
1695
|
+
const { ratio, metric } = gate;
|
|
1696
|
+
// A tail data does not explain, or one with no measured cause, is not skew: straggler reports
|
|
1697
|
+
// it, with the cause (it re-runs this same gate, see skewGate).
|
|
1698
|
+
const tail = tailVerdict(stage, thresholds.dataShareMin);
|
|
1699
|
+
if (tail.cause !== 'data') return null;
|
|
1199
1700
|
const value = Math.round(ratio * 10) / 10;
|
|
1200
|
-
const fix = skewFix(stage, ctx);
|
|
1701
|
+
const fix = skewFix(stage, ctx, false, tail);
|
|
1702
|
+
const dataRatio = tail.attribution?.dataRatio != null
|
|
1703
|
+
? Math.round(tail.attribution.dataRatio * 10) / 10 : undefined;
|
|
1201
1704
|
return {
|
|
1202
|
-
type: 'skew', stageId: stage.id, origin: fix.origin,
|
|
1705
|
+
type: 'skew', stageId: stage.id, origin: fix.origin, aqeSkew: fix.aqeSkew,
|
|
1706
|
+
cause: tail.cause,
|
|
1707
|
+
...(dataRatio !== undefined ? { dataRatio } : {}),
|
|
1203
1708
|
impactBand: 'warning',
|
|
1204
1709
|
metric, value,
|
|
1205
1710
|
confidence: skewConfidence(ratio, thresholds.ratioWarn),
|
|
1206
1711
|
validationRequired: `Flagged only when it costs at least ${shareLabel(thresholds.floorPctWarn)} of run time.`,
|
|
1207
|
-
recommendation: `Task duration ratio (${metric}) is ${value}
|
|
1712
|
+
recommendation: `Task duration ratio (${metric}) is ${value}×${dataRatio !== undefined ? `, and the slow tasks read a median ${dataRatio}× the data of the median task` : ', and the median task read almost nothing'}: ${fix.text}.`,
|
|
1208
1713
|
remediation: fix.remediation,
|
|
1209
1714
|
};
|
|
1210
1715
|
},
|
|
@@ -1221,8 +1726,7 @@ export const DETECTORS = [
|
|
|
1221
1726
|
emits: ['stageShape'],
|
|
1222
1727
|
docAnchor: '#bottleneck-stage-shape',
|
|
1223
1728
|
// lowParallelismFloorPct: the same 0.5% runtime floor the tiered detectors use. Parallelizing a
|
|
1224
|
-
// stage can't save more than the stage's own duration, so a shorter stage can't clear it
|
|
1225
|
-
// the 14 real logs that was 2839 of 3005 lowParallelism findings (2168 on sub-second stages).
|
|
1729
|
+
// stage can't save more than the stage's own duration, so a shorter stage can't clear it.
|
|
1226
1730
|
// App-wide idle capacity stays covered by utilization.
|
|
1227
1731
|
// taskStageSkew: stageShareMin is the share of the stage's wall-clock the longest task must
|
|
1228
1732
|
// span, and skewWarn how far past the median task it must run (skew's own max/median 3×).
|
|
@@ -1304,10 +1808,13 @@ export const DETECTORS = [
|
|
|
1304
1808
|
// Cores idle during the straggler's tail, at achieved concurrency (not full cluster
|
|
1305
1809
|
// capacity, which is lowParallelism's territory): resourceOnly, since skew and straggler
|
|
1306
1810
|
// already claim that tail's wall-clock time.
|
|
1307
|
-
const
|
|
1811
|
+
const tailIdleCoreMs =
|
|
1308
1812
|
Math.max(0, Math.min(totalCores, taskCount) - 1) *
|
|
1309
1813
|
Math.max(0, (stage.taskDurationMax ?? 0) - (stage.taskDurationP50 ?? 0));
|
|
1310
|
-
|
|
1814
|
+
// An upper bound that assumes every executor stays allocated through the tail, so it is
|
|
1815
|
+
// modeled; one stage cannot idle more than the whole run did, so it is clipped to that.
|
|
1816
|
+
const idleCoreMs = ctx.runIdleCoreMs == null ? tailIdleCoreMs : Math.min(tailIdleCoreMs, ctx.runIdleCoreMs);
|
|
1817
|
+
return costOnly('modeled', { value: idleCoreMs, unit: 'coreMs', idle: true });
|
|
1311
1818
|
}
|
|
1312
1819
|
return null;
|
|
1313
1820
|
},
|
|
@@ -1317,22 +1824,21 @@ export const DETECTORS = [
|
|
|
1317
1824
|
emits: ['shuffle'],
|
|
1318
1825
|
docAnchor: '#bottleneck-shuffle',
|
|
1319
1826
|
// stageFloorPct: the 0.5% runtime floor. The shuffle claim is clipped to the stage, so on a
|
|
1320
|
-
// shorter stage it
|
|
1321
|
-
// is still there on those stages; the floor is why they're dropped.
|
|
1827
|
+
// shorter stage it would grade info; the shuffle is still there, the floor drops the finding.
|
|
1322
1828
|
thresholds: { minBytes: 50 * MB, stageFloorPct: 0.005 },
|
|
1323
1829
|
detect(stage, ctx, thresholds) {
|
|
1324
1830
|
const bytes = stage.shuffleReadBytes;
|
|
1325
1831
|
if (bytes <= thresholds.minBytes) return null;
|
|
1326
1832
|
if (stageBelowRuntimeFloor(stage, ctx, thresholds.stageFloorPct)) return null;
|
|
1327
|
-
const { partitions, count } = shufflePartitionCase(stage, ctx
|
|
1833
|
+
const { partitions, count, key } = shufflePartitionCase(stage, ctx);
|
|
1328
1834
|
const perTask = formatBytes(bytes / Math.max(1, stage.taskCount));
|
|
1329
1835
|
const fix = partitions === 'sufficient'
|
|
1330
1836
|
? { text: `its ${stage.taskCount} tasks already read about ${perTask} each, so more partitions will not help: consider a broadcast join for the smaller side`, remediation: [] }
|
|
1331
1837
|
: partitions === 'aqeCoalesced'
|
|
1332
1838
|
? { text: `AQE coalesced it into ${stage.taskCount} tasks (from ${count} configured partitions): consider lowering ${ADVISORY_PARTITION_SIZE_KEY} or adding a broadcast join`, remediation: [decreaseConf(ADVISORY_PARTITION_SIZE_KEY)] }
|
|
1333
1839
|
: partitions === 'ownPartitioning'
|
|
1334
|
-
? { text:
|
|
1335
|
-
: { text:
|
|
1840
|
+
? { text: `${key} is already ${count}, so consider raising this stage's own partition count (its repartition(n) or RDD parallelism) or adding a broadcast join`, remediation: [] }
|
|
1841
|
+
: { text: `consider increasing ${key} or adding a broadcast join`, remediation: [increaseConf(key)] };
|
|
1336
1842
|
return {
|
|
1337
1843
|
type: 'shuffle', stageId: stage.id,
|
|
1338
1844
|
impactBand: 'info', partitions,
|
|
@@ -1352,8 +1858,11 @@ export const DETECTORS = [
|
|
|
1352
1858
|
const measuredMs = fetchWaitWallClockMs(stage);
|
|
1353
1859
|
const wasteMs = measuredMs == null ? modeledMs : Math.min(modeledMs, measuredMs);
|
|
1354
1860
|
// rawWaste: the measured byte volume behind the modeled figure.
|
|
1355
|
-
|
|
1356
|
-
|
|
1861
|
+
const capped = measuredMs != null && measuredMs < modeledMs;
|
|
1862
|
+
return withAssumptions(
|
|
1863
|
+
singleStageImpact(wasteMs, finding.stageId, ctx, capped ? 'measured' : 'modeled', { value: shuffleReadBytes, unit: 'bytes' }),
|
|
1864
|
+
`${ioModelAssumption(SHUFFLE_THROUGHPUT_BPS, stage)}; counts network transfer only${capped ? ', capped at the fetch wait the tasks measured' : ''}`,
|
|
1865
|
+
);
|
|
1357
1866
|
},
|
|
1358
1867
|
}),
|
|
1359
1868
|
defineStageDetector({
|
|
@@ -1373,13 +1882,13 @@ export const DETECTORS = [
|
|
|
1373
1882
|
const fix = skewFix(stage, ctx, true);
|
|
1374
1883
|
out.push({
|
|
1375
1884
|
type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
|
|
1376
|
-
rule: 'shufflePartitionSkew', origin: fix.origin, metric: 'shuffleReadMax', value: max,
|
|
1885
|
+
rule: 'shufflePartitionSkew', origin: fix.origin, aqeSkew: fix.aqeSkew, metric: 'shuffleReadMax', value: max,
|
|
1377
1886
|
recommendation: `The largest shuffle partition (${formatBytes(max)}) is ${ratioText}: ${fix.text}.`,
|
|
1378
1887
|
remediation: fix.remediation,
|
|
1379
1888
|
});
|
|
1380
1889
|
}
|
|
1381
1890
|
if (total >= thresholds.lowParTotalBytes && taskCount <= thresholds.lowParMaxTasks) {
|
|
1382
|
-
const fix = lowShuffleParallelismFix(stage, ctx
|
|
1891
|
+
const fix = lowShuffleParallelismFix(stage, ctx);
|
|
1383
1892
|
out.push({
|
|
1384
1893
|
type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
|
|
1385
1894
|
rule: 'lowShuffleParallelism', partitions: fix.partitions, metric: 'taskCount', value: taskCount,
|
|
@@ -1442,8 +1951,7 @@ export const DETECTORS = [
|
|
|
1442
1951
|
type: 'spill', order: 10, fixEffort: 'code', version: 1,
|
|
1443
1952
|
emits: ['spill'],
|
|
1444
1953
|
docAnchor: '#bottleneck-spill',
|
|
1445
|
-
// stageFloorPct: the 0.5% runtime floor, as for shuffle
|
|
1446
|
-
// logs, all info). The spill is still there on those stages; the floor is why they're dropped.
|
|
1954
|
+
// stageFloorPct: the 0.5% runtime floor, as for shuffle.
|
|
1447
1955
|
thresholds: { singleTaskDiskGiB: 1, singleTaskMemGiB: 4, highDiskGiB: 1, highTaskDiskMB: 512, highMemGiB: 4, medDiskMB: 256, medMemGiB: 1, skewRatio: 5, skewDiskFloorMB: 128, skewMemFloorMB: 256, skewMinTasks: 10, stageFloorPct: 0.005 },
|
|
1448
1956
|
detect(stage, ctx, thresholds) {
|
|
1449
1957
|
if (stage.memoryBytesSpilled === 0) return null;
|
|
@@ -1454,7 +1962,7 @@ export const DETECTORS = [
|
|
|
1454
1962
|
const impactBand = 'warning';
|
|
1455
1963
|
// shuffle.partitions sizes a shuffle's reduce side: a stage that reads no shuffle gets memory only.
|
|
1456
1964
|
const reads = stageReads(stage);
|
|
1457
|
-
const
|
|
1965
|
+
const shuffleFix = reads === 'shuffle' ? spillShuffleFix(stage, ctx) : null;
|
|
1458
1966
|
return {
|
|
1459
1967
|
type: 'spill', stageId: stage.id, impactBand, reads,
|
|
1460
1968
|
spillMagnitude: mag?.magnitude,
|
|
@@ -1465,10 +1973,10 @@ export const DETECTORS = [
|
|
|
1465
1973
|
: 'Spill cause could not be classified: inspect per-task spill metrics in the Spark UI before acting.',
|
|
1466
1974
|
recommendation: cls === 'skew'
|
|
1467
1975
|
? `${formatBytes(stage.memoryBytesSpilled)} spilled, skew-driven: fix task skew first; adding memory will not help.`
|
|
1468
|
-
:
|
|
1469
|
-
? `${formatBytes(stage.memoryBytesSpilled)} spilled:
|
|
1976
|
+
: shuffleFix
|
|
1977
|
+
? `${formatBytes(stage.memoryBytesSpilled)} spilled: ${shuffleFix.text}.`
|
|
1470
1978
|
: `${formatBytes(stage.memoryBytesSpilled)} spilled: this stage reads no shuffle, so increase executor memory or process less data per task.`,
|
|
1471
|
-
remediation: cls === 'skew' ? [] :
|
|
1979
|
+
remediation: cls === 'skew' ? [] : shuffleFix?.remediation ?? [increaseConf('spark.executor.memory')],
|
|
1472
1980
|
};
|
|
1473
1981
|
},
|
|
1474
1982
|
estimate(finding, ctx) {
|
|
@@ -1478,7 +1986,10 @@ export const DETECTORS = [
|
|
|
1478
1986
|
const diskBytesSpilled = stage.diskBytesSpilled ?? 0;
|
|
1479
1987
|
const wasteMs = (diskBytesSpilled / (SPILL_IO_THROUGHPUT_BPS * stageIoParallelism(stage))) * 1000;
|
|
1480
1988
|
// Surfaces the number the formula uses: the displayed metric is memoryBytesSpilled, but disk spill costs the I/O time.
|
|
1481
|
-
return
|
|
1989
|
+
return withAssumptions(
|
|
1990
|
+
singleStageImpact(wasteMs, finding.stageId, ctx, 'modeled', { value: diskBytesSpilled, unit: 'bytes' }),
|
|
1991
|
+
`${ioModelAssumption(SPILL_IO_THROUGHPUT_BPS, stage)}; counts disk spill only, not memory spill, serialization or GC`,
|
|
1992
|
+
);
|
|
1482
1993
|
},
|
|
1483
1994
|
}),
|
|
1484
1995
|
defineStageDetector({
|
|
@@ -1487,14 +1998,13 @@ export const DETECTORS = [
|
|
|
1487
1998
|
docAnchor: '#bottleneck-gc',
|
|
1488
1999
|
thresholds: {
|
|
1489
2000
|
warnPct100: 10,
|
|
1490
|
-
//
|
|
2001
|
+
// A GC share below this reads as over-provisioned memory (the low-GC note).
|
|
1491
2002
|
lowInfoPct100: 5,
|
|
1492
|
-
//
|
|
2003
|
+
// Noise floor so a stage that barely ran doesn't flag either direction.
|
|
1493
2004
|
minRunTimeMs: 10000,
|
|
1494
2005
|
// lowInfoFloorPct: the 0.5% runtime floor stageShape's lowParallelism uses. The low-GC note is
|
|
1495
2006
|
// an app-level memory-sizing signal; on a stage shorter than this share of the run it adds
|
|
1496
|
-
// nothing to that call.
|
|
1497
|
-
// findings); the low-GC pattern is still true on those stages, the floor is why they're dropped.
|
|
2007
|
+
// nothing to that call.
|
|
1498
2008
|
lowInfoFloorPct: 0.005,
|
|
1499
2009
|
},
|
|
1500
2010
|
detect(stage, ctx, thresholds) {
|
|
@@ -1511,8 +2021,14 @@ export const DETECTORS = [
|
|
|
1511
2021
|
remediation: [increaseConf('spark.executor.memory')],
|
|
1512
2022
|
};
|
|
1513
2023
|
}
|
|
1514
|
-
// Low-GC (cost) branch: only for stages that ran long enough to be meaningful
|
|
2024
|
+
// Low-GC (cost) branch: only for stages that ran long enough to be meaningful, and only as
|
|
2025
|
+
// the fallback sizing signal: a log with measured executor heap peaks and a known executor
|
|
2026
|
+
// memory gets memoryUtilization's heapOverProvisioned judgement instead (low GC time just
|
|
2027
|
+
// means a low allocation rate).
|
|
1515
2028
|
if ((stage.executorRunTime ?? 0) >= thresholds.minRunTimeMs
|
|
2029
|
+
&& !heapJudgementAvailable(ctx)
|
|
2030
|
+
// A stage that failed, or lost tasks, ran too little work to call its GC low.
|
|
2031
|
+
&& stage.stageFailureReason == null && stage.failedTasks === 0
|
|
1516
2032
|
&& pct < thresholds.lowInfoPct100
|
|
1517
2033
|
&& !stageBelowRuntimeFloor(stage, ctx, thresholds.lowInfoFloorPct)) {
|
|
1518
2034
|
const value = Math.round(pct * 10) / 10;
|
|
@@ -1551,7 +2067,7 @@ export const DETECTORS = [
|
|
|
1551
2067
|
},
|
|
1552
2068
|
}),
|
|
1553
2069
|
defineStageDetector({
|
|
1554
|
-
type: 'slowHost', order: 60, fixEffort: 'config', version:
|
|
2070
|
+
type: 'slowHost', order: 60, fixEffort: 'config', version: 2,
|
|
1555
2071
|
emits: ['slowHost'],
|
|
1556
2072
|
docAnchor: '#bottleneck-slow-host',
|
|
1557
2073
|
thresholds: {
|
|
@@ -1562,11 +2078,15 @@ export const DETECTORS = [
|
|
|
1562
2078
|
floorMs: 1000, floorBytes: 64 * MB,
|
|
1563
2079
|
// stageFloorPct: the tiered detectors' 0.5% runtime floor. On a stage shorter than this share
|
|
1564
2080
|
// of the run a slow host can't cost that much: duration findings are clipped to the stage
|
|
1565
|
-
// and
|
|
1566
|
-
// its band)
|
|
1567
|
-
// 324 of 452 slowHost findings, all info. The imbalance is still there on those stages; the
|
|
1568
|
-
// floor is why they're dropped.
|
|
2081
|
+
// and would grade info, and a byte-dimension imbalance (no time estimate, so its ratio tier
|
|
2082
|
+
// is its band) is graded info there too. So the stage is skipped.
|
|
1569
2083
|
stageFloorPct: 0.005,
|
|
2084
|
+
// outlierAlpha: the nominal chance a stage of alike tasks gets any executor flagged on task
|
|
2085
|
+
// time. The slowest of many executors always tops the median, so a task-time executor is
|
|
2086
|
+
// flagged only when its mean sits further above the median than the stage's own task spread
|
|
2087
|
+
// and its task count allow (chanceOutlierZ), more strictly the more executors there are.
|
|
2088
|
+
// The normal-tail bound behind that cutoff is loose: simulated alike stages flag well under it.
|
|
2089
|
+
outlierAlpha: 0.005,
|
|
1570
2090
|
},
|
|
1571
2091
|
detect(stage, ctx, thresholds) {
|
|
1572
2092
|
const hosts = stage.hostStats ?? [];
|
|
@@ -1574,9 +2094,10 @@ export const DETECTORS = [
|
|
|
1574
2094
|
if ((hosts.length < thresholds.minHosts && execs0.length < thresholds.minHosts) || stage.taskCount < thresholds.minTasks) return null;
|
|
1575
2095
|
if (stageBelowRuntimeFloor(stage, ctx, thresholds.stageFloorPct)) return null;
|
|
1576
2096
|
const out = [];
|
|
2097
|
+
const rule = speculationRule(ctx.app);
|
|
1577
2098
|
const speculation = switchFix(loggedAs(ctx.app, 'spark.speculation', true), 'spark.speculation', true,
|
|
1578
|
-
|
|
1579
|
-
|
|
2099
|
+
`check what it was running, and consider enabling spark.speculation to relaunch a lagging task automatically${rule ? ` (with this run's settings, ${rule})` : ''}`,
|
|
2100
|
+
`check what it was running; speculation is already on, so a lagging task there is already relaunched${rule ? ` (with this run's settings, ${rule})` : ''}`);
|
|
1580
2101
|
if (hosts.length >= thresholds.minHosts) {
|
|
1581
2102
|
const means = hosts.map(h => ({ host: h.host, taskCount: h.taskCount, mean: h.totalDuration / h.taskCount }));
|
|
1582
2103
|
const sorted = [...means].map(h => h.mean).sort((a, b) => a - b);
|
|
@@ -1622,8 +2143,15 @@ export const DETECTORS = [
|
|
|
1622
2143
|
const impactBandFor = (r ) =>
|
|
1623
2144
|
r >= tiers[3] ? 'critical' : (r >= tiers[1] ? 'warning' : (r >= tiers[0] ? 'info' : null));
|
|
1624
2145
|
const floorMs = thresholds.floorMs, floorBytes = thresholds.floorBytes;
|
|
1625
|
-
const
|
|
1626
|
-
|
|
2146
|
+
const taskTimeSamples = execs.filter(e => e.taskCount > 0).map(e => ({ key: e.executorId, value: e.totalDuration / e.taskCount }));
|
|
2147
|
+
const taskTimeZs = executorDurationZ(execs);
|
|
2148
|
+
const taskTimeZ = chanceOutlierZ(taskTimeSamples.length, thresholds.outlierAlpha);
|
|
2149
|
+
const dims = [
|
|
2150
|
+
{
|
|
2151
|
+
dimension: 'taskTime', floor: floorMs, samples: taskTimeSamples,
|
|
2152
|
+
// Only an executor the stage's own spread can't explain: with no known spread, none.
|
|
2153
|
+
qualifies: sample => (taskTimeZs?.get(sample.key) ?? -Infinity) >= taskTimeZ,
|
|
2154
|
+
},
|
|
1627
2155
|
{ dimension: 'inputBytes', floor: floorBytes, samples: execs.map(e => ({ key: e.executorId, value: e.inputBytes ?? 0 })) },
|
|
1628
2156
|
{ dimension: 'shuffleBytes', floor: floorBytes, samples: execs.map(e => ({ key: e.executorId, value: (e.shuffleReadBytes ?? 0) + (e.shuffleWriteBytes ?? 0) })) },
|
|
1629
2157
|
];
|
|
@@ -1633,14 +2161,13 @@ export const DETECTORS = [
|
|
|
1633
2161
|
dims.push({ dimension: 'storageMemory', floor: floorBytes, samples: [...em.entries()].map(([id, m]) => ({ key: id, value: (m.onHeapStorageMemory ?? 0) + (m.offHeapStorageMemory ?? 0) })) });
|
|
1634
2162
|
}
|
|
1635
2163
|
for (const d of dims) {
|
|
1636
|
-
const r = maxMedianRatio(d.samples);
|
|
2164
|
+
const r = maxMedianRatio(d.samples, d.qualifies);
|
|
1637
2165
|
if (!r || r.value < d.floor) continue; // absolute-magnitude floor: same ratio+floor shape as computeSpillMagnitude
|
|
1638
2166
|
const tier = impactBandFor(r.ratio);
|
|
1639
2167
|
if (!tier) continue;
|
|
1640
|
-
// taskTime is the only wallClock-bearing dimension
|
|
1641
|
-
//
|
|
1642
|
-
//
|
|
1643
|
-
// get a wallClock estimate, so they keep the dynamic tier unchanged.
|
|
2168
|
+
// taskTime is the only wallClock-bearing dimension: it takes the fixed fallback band (see
|
|
2169
|
+
// threshold-tables.md). The other three never get a wallClock estimate, so they keep the
|
|
2170
|
+
// ratio tier.
|
|
1644
2171
|
const impactBand = d.dimension === 'taskTime' ? 'info' : tier;
|
|
1645
2172
|
out.push({
|
|
1646
2173
|
type: 'slowHost', stageId: stage.id, impactBand,
|
|
@@ -1650,6 +2177,7 @@ export const DETECTORS = [
|
|
|
1650
2177
|
// in this dimension's own unit (ms for taskTime, bytes for the rest).
|
|
1651
2178
|
execMaxValue: r.value,
|
|
1652
2179
|
executorId: r.key,
|
|
2180
|
+
...(d.dimension === 'taskTime' ? executorTaskTimeCaveat(r.key, r.ratio, d.samples.length, stage.taskCount) : {}),
|
|
1653
2181
|
recommendation: `Executor ${r.key}'s ${SLOW_HOST_DIMENSION_LABEL[d.dimension]} is ${Math.round(r.ratio * 10) / 10}× the median: investigate uneven partition assignment or a degraded executor.`,
|
|
1654
2182
|
});
|
|
1655
2183
|
}
|
|
@@ -1683,7 +2211,7 @@ export const DETECTORS = [
|
|
|
1683
2211
|
thresholds: { infoMin: 15 },
|
|
1684
2212
|
// A stage slowHost already explains needs no generic "this stage is slow" finding on top.
|
|
1685
2213
|
suppressedBy: 'slowHost',
|
|
1686
|
-
detect(stage,
|
|
2214
|
+
detect(stage, ctx, thresholds) {
|
|
1687
2215
|
// Basis is real wall-clock stage duration, not per-executor average; the impact-estimator
|
|
1688
2216
|
// formula reuses this exact stageDurationMs computation.
|
|
1689
2217
|
const stageDurationMs = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
|
|
@@ -1693,11 +2221,12 @@ export const DETECTORS = [
|
|
|
1693
2221
|
const impactBand = durationMinutes >= t.infoMin ? 'info' : null;
|
|
1694
2222
|
if (!impactBand) return null;
|
|
1695
2223
|
const value = Math.round(durationMinutes * 10) / 10;
|
|
1696
|
-
// shuffle
|
|
1697
|
-
//
|
|
2224
|
+
// A shuffle's reduce-side partition count is the remedy on a stage that reads a shuffle; a stage
|
|
2225
|
+
// that reads input files gets the input-partitioning remedy instead.
|
|
1698
2226
|
const reads = stageReads(stage);
|
|
1699
|
-
const
|
|
1700
|
-
|
|
2227
|
+
const shuffleFix = reads === 'shuffle' ? slowShuffleFix(stage, ctx) : null;
|
|
2228
|
+
const recommendation = shuffleFix
|
|
2229
|
+
? `This stage ran ${value} minutes with no more specific cause flagged: often a partition-count problem, ${shuffleFix.text}, or check for a large per-task data volume driving heavy shuffle and spill.`
|
|
1701
2230
|
: reads === 'input'
|
|
1702
2231
|
? `This stage ran ${value} minutes with no more specific cause flagged and reads mostly input files: often too few or too uneven input partitions, so check input file sizes and lower spark.sql.files.maxPartitionBytes, or look for a large per-task data volume driving heavy spill.`
|
|
1703
2232
|
: `This stage ran ${value} minutes with no more specific cause flagged and reads neither shuffle nor input files: check what it computes and for a large per-task data volume driving heavy spill.`;
|
|
@@ -1705,8 +2234,8 @@ export const DETECTORS = [
|
|
|
1705
2234
|
type: 'stageSlowness', stageId: stage.id, impactBand,
|
|
1706
2235
|
metric: 'stageDurationMinutes', value, reads,
|
|
1707
2236
|
recommendation,
|
|
1708
|
-
remediation:
|
|
1709
|
-
?
|
|
2237
|
+
remediation: shuffleFix
|
|
2238
|
+
? shuffleFix.remediation
|
|
1710
2239
|
: reads === 'input' ? [decreaseConf('spark.sql.files.maxPartitionBytes')] : [],
|
|
1711
2240
|
};
|
|
1712
2241
|
},
|
|
@@ -1721,8 +2250,7 @@ export const DETECTORS = [
|
|
|
1721
2250
|
// too, hence TAIL_CLAIM's post-fix floor. Unknown cluster size: no defensible figure.
|
|
1722
2251
|
if (ctx.totalCores <= 0) return costOnly('modeled');
|
|
1723
2252
|
// A stage that read no input and no shuffle, its tasks idle waiting on an external system,
|
|
1724
|
-
// gains nothing from more partitions
|
|
1725
|
-
// was claimed 99% recoverable): claim 0. Stages that read bytes keep their claim.
|
|
2253
|
+
// gains nothing from more partitions: claim 0. Stages that read bytes keep their claim.
|
|
1726
2254
|
const readBytes = (stage.inputBytes ?? 0) + (stage.shuffleReadBytes ?? 0);
|
|
1727
2255
|
const activeMs = typeof stage.taskActiveMs === 'number'
|
|
1728
2256
|
? stage.taskActiveMs
|
|
@@ -1793,63 +2321,39 @@ export const DETECTORS = [
|
|
|
1793
2321
|
// floorPctWarn/floorPctCrit default to impact-band.ts's run-wide noise floor, so a tail this
|
|
1794
2322
|
// gate admits at its warn floor grades at least warning there too.
|
|
1795
2323
|
// shareWarnAtFloor: in a large stage, the few stragglers that gate it for tens of seconds can be
|
|
1796
|
-
// only 2.5-5% of its tasks
|
|
1797
|
-
// (recoverable = replay with each task over 4x P50 capped at P50), admitting 2.5-5% shares
|
|
1798
|
-
// whose clipped tail already clears floorPctWarn found 3 such stages (10-48s) for 1 borderline
|
|
1799
|
-
// miss; admitting every 2.5% share instead added 86 findings below the floor.
|
|
2324
|
+
// only 2.5-5% of its tasks, so a 2.5% share counts once its clipped tail clears floorPctWarn.
|
|
1800
2325
|
// A stage shorter than floorPctWarn of the run is skipped outright: its tail can't cost more
|
|
1801
|
-
// than the stage's own duration, so every finding there
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
2326
|
+
// than the stage's own duration, so every finding there would grade info.
|
|
2327
|
+
// A tail skew's gate admits and data explains is skew's finding; one it admits whose cause is not
|
|
2328
|
+
// data is this one's. Both use skew's thresholds (ctx.skewThresholds), so tuning skew moves both.
|
|
1804
2329
|
thresholds: { minTasks: 10, shareWarn: 0.05, shareWarnAtFloor: 0.025, warnPct: 0.10, critPct: 0.20, floorPctWarn: IMPACT_FLOOR_PCT_WARN, floorPctCrit: IMPACT_FLOOR_PCT_CRIT },
|
|
1805
2330
|
detect(stage, ctx, thresholds) {
|
|
1806
|
-
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
const
|
|
1813
|
-
|
|
1814
|
-
//
|
|
1815
|
-
const
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
const stragglerShareFires = stragglerShare > thresholds.shareWarn
|
|
1821
|
-
|| (stragglerShare > thresholds.shareWarnAtFloor && runMs != null && meetsWarnFloor);
|
|
1822
|
-
if (!useSpeculative && !stragglerShareFires) return null;
|
|
1823
|
-
const speculativeTier = speculativeShare >= thresholds.critPct && meetsCritFloor ? 'critical'
|
|
1824
|
-
: speculativeShare >= thresholds.warnPct && meetsWarnFloor ? 'warning' : 'info';
|
|
1825
|
-
// Straggler share has no dedicated critical tier per detector-contract.md; only warning.
|
|
1826
|
-
const stragglerTier = stragglerShareFires && meetsWarnFloor ? 'warning' : 'info';
|
|
1827
|
-
// Fixed fallback: overwritten by deriveImpactBand when this finding gets a real wallClock
|
|
1828
|
-
// estimate (the common case). Only surfaces on the rare occupancy-sweep miss.
|
|
1829
|
-
const impactBand = 'info';
|
|
1830
|
-
// Report whichever signal actually drove the finding, not just whether speculation was on:
|
|
1831
|
-
// a high stragglerShare with few speculative retries must not be reported as a low-value
|
|
1832
|
-
// speculativeTasks count. Ties keep the speculative-driven default.
|
|
1833
|
-
const useSpeculativeMetric = useSpeculative && !(IMPACT_BAND_ORDER[stragglerTier] < IMPACT_BAND_ORDER[speculativeTier]);
|
|
1834
|
-
const value = useSpeculativeMetric ? stage.speculativeTasks : Math.round(stragglerShare * 100);
|
|
1835
|
-
const detail = useSpeculativeMetric
|
|
1836
|
-
? `${value} speculative attempt${value === 1 ? '' : 's'} discarded`
|
|
1837
|
-
: `${value}% of tasks straggled`;
|
|
1838
|
-
// The skew advice fits only a stage that reads a shuffle feeding a join (skewFix).
|
|
1839
|
-
const fix = skewFix(stage, ctx);
|
|
2331
|
+
const skew = ctx.skewThresholds;
|
|
2332
|
+
const tail = tailVerdict(stage, skew.dataShareMin);
|
|
2333
|
+
const skewTail = skewGate(stage, ctx, skew.ratioWarn, skew.minTasksForP95, skew.floorPctWarn);
|
|
2334
|
+
if (tail.cause === 'data' && skewTail !== null) return null; // skew reports it
|
|
2335
|
+
// A tail skew's duration gate admits but data does not explain (or nothing attributes) is this finding's.
|
|
2336
|
+
const fromSkewGate = skewTail !== null && tail.cause !== 'data';
|
|
2337
|
+
const own = stragglerGate(stage, ctx, thresholds);
|
|
2338
|
+
if (own === null && !fromSkewGate) return null;
|
|
2339
|
+
// Through skew's gate alone the tail can be a share too small to state, so skew's ratio describes it.
|
|
2340
|
+
const reported = own !== null
|
|
2341
|
+
? { metric: own.useSpeculativeMetric ? 'speculativeTasks' : 'stragglerShare', value: own.value, unit: own.useSpeculativeMetric ? 'count' : 'pct' , detail: own.detail }
|
|
2342
|
+
: { metric: skewTail .metric, value: Math.round(skewTail .ratio * 10) / 10, unit: 'ratio' ,
|
|
2343
|
+
detail: `Task duration ratio (${skewTail .metric}) is ${Math.round(skewTail .ratio * 10) / 10}×` };
|
|
2344
|
+
const advice = stragglerAdvice(stage, ctx, tail, reported.detail);
|
|
1840
2345
|
return {
|
|
1841
|
-
type: 'straggler', stageId: stage.id,
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
2346
|
+
type: 'straggler', stageId: stage.id,
|
|
2347
|
+
impactBand: 'info', ...advice.evidence,
|
|
2348
|
+
metric: reported.metric,
|
|
2349
|
+
value: reported.value,
|
|
2350
|
+
unit: reported.unit,
|
|
1845
2351
|
speculativeTasks: stage.speculativeTasks ?? 0,
|
|
1846
2352
|
stragglerCount: stage.stragglerCount ?? 0,
|
|
1847
|
-
confidence:
|
|
1848
|
-
? stragglerConfidence(speculativeShare, thresholds.warnPct, thresholds.critPct)
|
|
1849
|
-
: stragglerConfidence(stragglerShare, thresholds.shareWarn, thresholds.critPct),
|
|
2353
|
+
confidence: own?.confidence ?? skewConfidence(skewTail .ratio, skew.ratioWarn),
|
|
1850
2354
|
validationRequired: `Warning needs at least ${shareLabel(thresholds.floorPctWarn)} of run time at stake, critical ${shareLabel(thresholds.floorPctCrit)}.`,
|
|
1851
|
-
recommendation:
|
|
1852
|
-
remediation:
|
|
2355
|
+
recommendation: advice.recommendation,
|
|
2356
|
+
remediation: advice.remediation,
|
|
1853
2357
|
};
|
|
1854
2358
|
},
|
|
1855
2359
|
estimate(finding, ctx) {
|
|
@@ -1864,7 +2368,7 @@ export const DETECTORS = [
|
|
|
1864
2368
|
emits: ['speculationWaste'],
|
|
1865
2369
|
docAnchor: '#bottleneck-speculation-waste',
|
|
1866
2370
|
thresholds: { minWasted: 5, minWasteMs: 60000 },
|
|
1867
|
-
detect(stage,
|
|
2371
|
+
detect(stage, ctx, thresholds) {
|
|
1868
2372
|
const wasted = stage.speculationWastedAttempts ?? 0;
|
|
1869
2373
|
const wastedMs = stage.speculationWasteMs ?? 0;
|
|
1870
2374
|
if (wasted < thresholds.minWasted || wastedMs < thresholds.minWasteMs) return null;
|
|
@@ -1873,7 +2377,7 @@ export const DETECTORS = [
|
|
|
1873
2377
|
impactBand: 'warning',
|
|
1874
2378
|
metric: 'speculationWasteMs', value: wastedMs,
|
|
1875
2379
|
confidence: speculationWasteConfidence(wastedMs, thresholds.minWasteMs),
|
|
1876
|
-
recommendation: `Speculative execution discarded ${Math.round(wastedMs / 1000)}s of executor time in this stage: if task durations are naturally variable rather than genuine stragglers, consider tuning spark.speculation.multiplier/quantile.`,
|
|
2380
|
+
recommendation: `Speculative execution discarded ${Math.round(wastedMs / 1000)}s of executor time in this stage: if task durations are naturally variable rather than genuine stragglers, consider tuning spark.speculation.multiplier/quantile${speculationRuleNote(ctx.app)}.`,
|
|
1877
2381
|
remediation: [increaseConf('spark.speculation.multiplier'), increaseConf('spark.speculation.quantile')],
|
|
1878
2382
|
};
|
|
1879
2383
|
},
|
|
@@ -1921,8 +2425,7 @@ export const DETECTORS = [
|
|
|
1921
2425
|
emits: ['tinyTask'],
|
|
1922
2426
|
docAnchor: '#bottleneck-tiny-tasks',
|
|
1923
2427
|
// stageFloorPct: the tiered detectors' 0.5% runtime floor. Coalescing can't save more than the
|
|
1924
|
-
// stage's own duration, so on a shorter stage every finding
|
|
1925
|
-
// 132 of 151 tinyTask findings. The tasks are still tiny there; the floor is why they're dropped.
|
|
2428
|
+
// stage's own duration, so on a shorter stage every finding would grade info.
|
|
1926
2429
|
thresholds: { minTasks: 100, maxP50: 500, maxP95: 1000, stageFloorPct: 0.005 },
|
|
1927
2430
|
detect(stage, ctx, thresholds) {
|
|
1928
2431
|
if (stage.taskCount < thresholds.minTasks) return null;
|
|
@@ -1930,14 +2433,12 @@ export const DETECTORS = [
|
|
|
1930
2433
|
if (stage.taskDurationP50 > thresholds.maxP50 || stage.taskDurationP95 > thresholds.maxP95) return null;
|
|
1931
2434
|
const coalesceTo = Math.max(1, Math.round(stage.taskCount / 10));
|
|
1932
2435
|
const reads = stageReads(stage);
|
|
1933
|
-
const fix = reads === 'shuffle'
|
|
1934
|
-
? `lower spark.sql.shuffle.partitions or .coalesce(${coalesceTo})`
|
|
1935
|
-
: `.coalesce(${coalesceTo})`;
|
|
2436
|
+
const fix = reads === 'shuffle' ? tinyShuffleFix(stage, ctx, coalesceTo) : { text: `.coalesce(${coalesceTo})`, remediation: [] };
|
|
1936
2437
|
return {
|
|
1937
2438
|
type: 'tinyTask', stageId: stage.id, impactBand: 'info', reads,
|
|
1938
2439
|
metric: 'taskDurationP50', value: Math.round(stage.taskDurationP50),
|
|
1939
|
-
recommendation: `Many small tasks (${stage.taskCount}, P50 ${Math.round(stage.taskDurationP50)}ms): scheduler overhead may dominate. Try ${fix}.`,
|
|
1940
|
-
remediation:
|
|
2440
|
+
recommendation: `Many small tasks (${stage.taskCount}, P50 ${Math.round(stage.taskDurationP50)}ms): scheduler overhead may dominate. Try ${fix.text}.`,
|
|
2441
|
+
remediation: fix.remediation,
|
|
1941
2442
|
};
|
|
1942
2443
|
},
|
|
1943
2444
|
estimate(finding, ctx) {
|
|
@@ -1990,10 +2491,7 @@ export const DETECTORS = [
|
|
|
1990
2491
|
// No stage ever recorded a submission timestamp: no basis to measure a startup gap against.
|
|
1991
2492
|
if (!Number.isFinite(firstStageSubmitted)) return null;
|
|
1992
2493
|
// The wait is from the first runnable stage to the first executor, not from app start: the
|
|
1993
|
-
// driver's own startup before its first job
|
|
1994
|
-
// executors did) isn't something executors could shorten. On the 9 real logs that fired,
|
|
1995
|
-
// the first executor arrived 146-192s after the first stage on two whose old gap read ~40s,
|
|
1996
|
-
// and 2s after it on one the old gap flagged critical at 42s.
|
|
2494
|
+
// driver's own startup before its first job isn't something executors could shorten.
|
|
1997
2495
|
// An executor added before the first stage only counts if it was still alive at submission:
|
|
1998
2496
|
// with dynamic allocation scaling to zero, early executors can idle out before any job runs.
|
|
1999
2497
|
const removedAt = new Map ();
|
|
@@ -2036,7 +2534,6 @@ export const DETECTORS = [
|
|
|
2036
2534
|
thresholds: { minUtil: 0.60 },
|
|
2037
2535
|
detect(ctx, thresholds) {
|
|
2038
2536
|
const { app, executorsAdded, executorsRemoved, runAggregates } = ctx;
|
|
2039
|
-
// Nullish (not falsy) check: a literal startTime:0 must not be treated as "missing".
|
|
2040
2537
|
if (!app || executorsAdded.length === 0 || app.startTime == null || app.endTime == null) return null;
|
|
2041
2538
|
const appDuration = app.endTime - app.startTime;
|
|
2042
2539
|
if (appDuration <= 0) return null;
|
|
@@ -2052,13 +2549,12 @@ export const DETECTORS = [
|
|
|
2052
2549
|
// Busy core-time (from the whole-run core-time-series, same signal memoryUtilization's
|
|
2053
2550
|
// idleCores variant already uses), not executor lifetime: an executor that exists for the
|
|
2054
2551
|
// whole run but sits fully idle must not score as 100% used. Missing runAggregates (older
|
|
2055
|
-
// callers, synthetic fixtures) reads as 0 busy time
|
|
2056
|
-
// lifetime-based measure this replaces.
|
|
2552
|
+
// callers, synthetic fixtures) reads as 0 busy time.
|
|
2057
2553
|
const busyCoreMs = runAggregates?.busyCoreMs ?? 0;
|
|
2058
2554
|
const utilization = busyCoreMs / capacityCoreMs;
|
|
2059
2555
|
if (utilization >= thresholds.minUtil) return null;
|
|
2060
2556
|
|
|
2061
|
-
// CPU-time-based utilization
|
|
2557
|
+
// CPU-time-based utilization: metric only, no threshold.
|
|
2062
2558
|
// Null when no stage recorded CPU time (older Spark), the same rule as the CLI metrics block.
|
|
2063
2559
|
const cpuMs = totalExecutorCpuMs(ctx.stages.values());
|
|
2064
2560
|
const cpuUtilizationPct = cpuMs == null ? null : Math.round((cpuMs / capacityCoreMs) * 100);
|
|
@@ -2098,26 +2594,20 @@ export const DETECTORS = [
|
|
|
2098
2594
|
emits: ['memoryUtilization'],
|
|
2099
2595
|
docAnchor: '#bottleneck-memory-utilization',
|
|
2100
2596
|
thresholds: {
|
|
2101
|
-
idleCoreWarn: 0.50, //
|
|
2102
|
-
bandTooSmall: 0.95, // MemoryAlertsReducer: used/allocated
|
|
2597
|
+
idleCoreWarn: 0.50, // idle share of allocated core time above which idleCores fires
|
|
2103
2598
|
bandTooHigh: 0.70, // below this => over-provisioned (cost signal)
|
|
2104
|
-
wasteBufferMultiplier: 1.5, //
|
|
2599
|
+
wasteBufferMultiplier: 1.5, // wasted memory-time must exceed used by this multiple
|
|
2105
2600
|
},
|
|
2106
2601
|
detect(ctx, thresholds) {
|
|
2107
2602
|
const { app, executorsAdded, executorsRemoved, runAggregates, stages } = ctx;
|
|
2108
2603
|
const out = [];
|
|
2109
|
-
// Nullish (not falsy) check: a literal startTime:0 must not be treated as "missing".
|
|
2110
2604
|
if (app?.startTime == null || app?.endTime == null) return out;
|
|
2111
2605
|
const appDurationMs = app.endTime - app.startTime;
|
|
2112
2606
|
if (appDurationMs <= 0) return out;
|
|
2113
2607
|
|
|
2114
|
-
// Peak concurrent executors/cores (not executorsAdded.length/computeTotalCores): a
|
|
2115
|
-
// cumulative sum or count double-counts a churned-through executor against its replacement's
|
|
2116
|
-
// (spot preemption, dynamicAllocation replacement), inflating idle-rate and waste-model figures.
|
|
2117
|
-
const peakExecutors = computePeakConcurrentExecutorCount(executorsAdded, executorsRemoved);
|
|
2118
2608
|
const allocatedMB = app.resources?.executor?.memoryMB ?? null;
|
|
2119
2609
|
|
|
2120
|
-
//
|
|
2610
|
+
// Idle-cores rate.
|
|
2121
2611
|
const busyCoreMs = runAggregates?.busyCoreMs;
|
|
2122
2612
|
// Allocated core-time, as for `utilization`, so the two findings of one idle condition agree.
|
|
2123
2613
|
const allocationInput = { app, stages, executors: { added: executorsAdded, removed: executorsRemoved } };
|
|
@@ -2141,63 +2631,63 @@ export const DETECTORS = [
|
|
|
2141
2631
|
}
|
|
2142
2632
|
}
|
|
2143
2633
|
|
|
2144
|
-
//
|
|
2145
|
-
//
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
if (!(em instanceof Map)) continue;
|
|
2150
|
-
for (const [execId, m] of em) {
|
|
2151
|
-
const heap = m?.jvmHeapMemory ?? 0;
|
|
2152
|
-
if (heap > (peakHeapByExec.get(execId) ?? 0)) peakHeapByExec.set(execId, heap);
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2634
|
+
// Memory band (executor only: there is no driver heap metric).
|
|
2635
|
+
// spark.executor.memory is one setting for every executor, so the judgement is made once, on
|
|
2636
|
+
// the executor with the highest sampled heap peak. A near-capacity peak is not reported:
|
|
2637
|
+
// JVMHeapMemory counts uncollected garbage, so a peak near -Xmx is normal JVM behaviour.
|
|
2638
|
+
const peakHeapByExec = executorHeapPeaks(ctx);
|
|
2155
2639
|
if (peakHeapByExec.size === 0) {
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2640
|
+
// Spark 3.0+ writes the executor's metric peaks on every TaskEnd, but it samples them at the
|
|
2641
|
+
// executor heartbeat (spark.executor.metrics.pollingInterval defaults to 0), so a task that
|
|
2642
|
+
// finishes between two heartbeats reports zeros, as does every task in local mode.
|
|
2643
|
+
// spark.eventLog.logStageExecutorMetrics logs the per-stage peaks the driver gathers.
|
|
2644
|
+
const stageMetrics = switchFix(loggedAs(app, LOG_STAGE_EXECUTOR_METRICS_KEY, true), LOG_STAGE_EXECUTOR_METRICS_KEY, true,
|
|
2645
|
+
` To measure it, set ${LOG_STAGE_EXECUTOR_METRICS_KEY}=true to record the peaks per stage.`,
|
|
2646
|
+
` ${LOG_STAGE_EXECUTOR_METRICS_KEY} is already on, so no executor ran long enough to be sampled.`);
|
|
2160
2647
|
out.push({
|
|
2161
2648
|
type: 'memoryUtilization', variant: 'memoryBand', stageId: null,
|
|
2162
2649
|
impactBand: 'info', metric: 'memoryBand', dataUnavailable: true,
|
|
2163
|
-
recommendation:
|
|
2164
|
-
remediation:
|
|
2650
|
+
recommendation: `Executor heap peaks are missing from this log: Spark samples them at the executor heartbeat, so tasks shorter than the heartbeat interval report zeros (and local mode reports zeros throughout), and executor memory sizing was not measured.${stageMetrics.text}`,
|
|
2651
|
+
remediation: stageMetrics.remediation,
|
|
2165
2652
|
});
|
|
2166
2653
|
} else if (allocatedMB != null && allocatedMB > 0) {
|
|
2167
2654
|
const allocatedBytes = allocatedMB * 1024 * 1024;
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
remediation: [decreaseConf('spark.executor.memory')],
|
|
2190
|
-
});
|
|
2191
|
-
}
|
|
2655
|
+
let execId = '';
|
|
2656
|
+
let heap = 0;
|
|
2657
|
+
for (const [id, peak] of peakHeapByExec) {
|
|
2658
|
+
if (peak > heap) { heap = peak; execId = id; }
|
|
2659
|
+
}
|
|
2660
|
+
const ratio = heap / allocatedBytes;
|
|
2661
|
+
if (ratio < thresholds.bandTooHigh) {
|
|
2662
|
+
const pct = Math.round(ratio * 100);
|
|
2663
|
+
const executorCount = peakHeapByExec.size;
|
|
2664
|
+
out.push({
|
|
2665
|
+
type: 'memoryUtilization', variant: 'memoryBand', rule: 'heapOverProvisioned',
|
|
2666
|
+
stageId: null, executorId: execId,
|
|
2667
|
+
impactBand: 'info', metric: 'heapUsedRatio', value: pct,
|
|
2668
|
+
// Absolute figures behind the rounded ratio, for the estimator's unused-memory model:
|
|
2669
|
+
// the unused heap is held by every executor for as long as it is alive.
|
|
2670
|
+
allocatedBytes, heap, executorCount,
|
|
2671
|
+
executorSeconds: computeAllocation({ app, stages, executors: { added: executorsAdded, removed: executorsRemoved } }).executorSeconds
|
|
2672
|
+
?? executorCount * (appDurationMs / 1000),
|
|
2673
|
+
recommendation: `The busiest of ${executorCount} sampled executors peaked at ${pct}% of allocated heap (a lower bound: Spark samples at executor heartbeat): memory may be over-provisioned; consider reducing spark.executor.memory for cost savings.`,
|
|
2674
|
+
remediation: [decreaseConf('spark.executor.memory')],
|
|
2675
|
+
});
|
|
2192
2676
|
}
|
|
2193
2677
|
}
|
|
2194
2678
|
|
|
2195
|
-
//
|
|
2196
|
-
|
|
2197
|
-
|
|
2679
|
+
// Spark Memory Limit waste model.
|
|
2680
|
+
// executorRunTime sums task time across cores, so dividing by the cores per executor gives
|
|
2681
|
+
// executor-seconds: the same unit as the allocated executor-seconds it is compared with.
|
|
2682
|
+
// The allocated side is the executors' alive time (computeAllocation), not the peak executor
|
|
2683
|
+
// count held for the whole run: under dynamic allocation the two differ by peak over mean.
|
|
2684
|
+
const allocation = computeAllocation(allocationInput);
|
|
2685
|
+
const executorCores = app.resources?.executor?.cores ?? allocation.executorCores;
|
|
2686
|
+
if (allocatedMB != null && allocation.executorSeconds != null && allocation.executorSeconds > 0 && executorCores != null && executorCores > 0) {
|
|
2687
|
+
const allocatedMBSeconds = allocatedMB * allocation.executorSeconds;
|
|
2198
2688
|
let usedRunTimeMs = 0;
|
|
2199
2689
|
for (const s of stages.values()) usedRunTimeMs += s.executorRunTime ?? 0;
|
|
2200
|
-
const usedMBSeconds = allocatedMB * (usedRunTimeMs / 1000);
|
|
2690
|
+
const usedMBSeconds = allocatedMB * (usedRunTimeMs / 1000 / executorCores);
|
|
2201
2691
|
const wastedMBSeconds = allocatedMBSeconds - usedMBSeconds;
|
|
2202
2692
|
if (wastedMBSeconds > thresholds.wasteBufferMultiplier * usedMBSeconds) {
|
|
2203
2693
|
const value = Math.round(wastedMBSeconds);
|
|
@@ -2229,15 +2719,15 @@ export const DETECTORS = [
|
|
|
2229
2719
|
}
|
|
2230
2720
|
return costOnly('modeled');
|
|
2231
2721
|
}
|
|
2232
|
-
// Only the over-provisioned band is a waste; the
|
|
2233
|
-
//
|
|
2722
|
+
// Only the over-provisioned band is a waste; the dataUnavailable shape has no inputs and
|
|
2723
|
+
// stays informational.
|
|
2234
2724
|
if (finding.variant === 'memoryBand' && finding.rule === 'heapOverProvisioned') {
|
|
2235
2725
|
const allocatedBytes = finding.allocatedBytes ;
|
|
2236
2726
|
const heap = finding.heap ;
|
|
2237
|
-
const
|
|
2238
|
-
if (allocatedBytes != null && heap != null &&
|
|
2727
|
+
const executorSeconds = finding.executorSeconds ;
|
|
2728
|
+
if (allocatedBytes != null && heap != null && executorSeconds != null) {
|
|
2239
2729
|
const unusedMB = (allocatedBytes - heap) / (1024 * 1024);
|
|
2240
|
-
const wastedMBSeconds = unusedMB *
|
|
2730
|
+
const wastedMBSeconds = unusedMB * executorSeconds;
|
|
2241
2731
|
return costOnly('modeled', { value: wastedMBSeconds, unit: 'mbSeconds' });
|
|
2242
2732
|
}
|
|
2243
2733
|
}
|
|
@@ -2264,7 +2754,7 @@ export const DETECTORS = [
|
|
|
2264
2754
|
const out = [];
|
|
2265
2755
|
let persistedRddCount = 0;
|
|
2266
2756
|
// With block-update logging on, zero rdd_* updates means nothing was ever cached, not a gap.
|
|
2267
|
-
const blockUpdatesLogged =
|
|
2757
|
+
const blockUpdatesLogged = loggedAs(ctx.app, 'spark.eventLog.logBlockUpdates.enabled', true);
|
|
2268
2758
|
// Spark before 2.3 has no block-update logging and writes RDD Info's cache figures only on
|
|
2269
2759
|
// StageCompleted, which isn't read: the caveat's advice doesn't apply there. A log with no
|
|
2270
2760
|
// version is pre-1.3 (no SparkListenerLogStart); every 2.3+ log records one.
|
|
@@ -2311,7 +2801,10 @@ export const DETECTORS = [
|
|
|
2311
2801
|
const uncachedBytes = numCachedPartitions > 0 ? (cachedBytes / numCachedPartitions) * numUncachedPartitions : 0;
|
|
2312
2802
|
const uncachedOrSpilledBytes = uncachedBytes + diskSize;
|
|
2313
2803
|
const wasteMs = (uncachedOrSpilledBytes / RE_READ_THROUGHPUT_BPS) * 1000;
|
|
2314
|
-
return
|
|
2804
|
+
return withAssumptions(
|
|
2805
|
+
costOnly('modeled', { value: wasteMs, unit: 'ms' }),
|
|
2806
|
+
`assumes ${Math.round(RE_READ_THROUGHPUT_BPS / 1e6)} MB/s per reading task, summed as task time and not divided over executors`,
|
|
2807
|
+
);
|
|
2315
2808
|
},
|
|
2316
2809
|
}),
|
|
2317
2810
|
defineAppDetector({
|
|
@@ -2581,8 +3074,13 @@ export const DETECTORS = [
|
|
|
2581
3074
|
},
|
|
2582
3075
|
estimate(finding) {
|
|
2583
3076
|
const totalReadBytes = (finding.totalReadBytes ) ?? 0;
|
|
2584
|
-
|
|
2585
|
-
|
|
3077
|
+
// The measured scan volume and the query count, not a time: the bytes are cumulative task
|
|
3078
|
+
// input across queries, and a re-read cost depends on how much of it a cache would serve.
|
|
3079
|
+
const queries = (finding.executionIds )?.length ?? 0;
|
|
3080
|
+
return withAssumptions(
|
|
3081
|
+
costOnly('measured', { value: totalReadBytes, unit: 'bytes', reread: true }),
|
|
3082
|
+
`counts the bytes ${queries} ${queries === 1 ? 'query' : 'queries'} scanned from the same relation, matched by plan; a cache would still read it once`,
|
|
3083
|
+
);
|
|
2586
3084
|
},
|
|
2587
3085
|
}),
|
|
2588
3086
|
defineAppDetector({
|
|
@@ -2625,43 +3123,18 @@ export const DETECTORS = [
|
|
|
2625
3123
|
return costOnly('modeled', { value: coreHoursIsh, unit: 'coreHours' });
|
|
2626
3124
|
},
|
|
2627
3125
|
}),
|
|
2628
|
-
// ── Config-sanity entries (scope:'config', inScorecard:false) ────────────────
|
|
2629
|
-
defineConfigDetector({
|
|
2630
|
-
type: 'configAudit', order: 120, fixEffort: 'config', version: 1, inScorecard: false,
|
|
2631
|
-
emits: ['configAudit'],
|
|
2632
|
-
docAnchor: '#config-shuffle-service', thresholds: {}, property: 'spark.shuffle.service.enabled',
|
|
2633
|
-
detect(target) {
|
|
2634
|
-
const res = target.app?.resources ?? null;
|
|
2635
|
-
if (res?.dynamicAllocationEnabled === true && res?.shuffleServiceEnabled === false) {
|
|
2636
|
-
return {
|
|
2637
|
-
type: 'configAudit', property: 'spark.shuffle.service.enabled',
|
|
2638
|
-
impactBand: 'warning', metric: 'config', valueText: 'false',
|
|
2639
|
-
recommendation: 'Dynamic allocation is on but the external shuffle service is off: set spark.shuffle.service.enabled=true so shuffle data survives executor removal.',
|
|
2640
|
-
remediation: setConfUnlessLogged(target.app, 'spark.shuffle.service.enabled', true),
|
|
2641
|
-
};
|
|
2642
|
-
}
|
|
2643
|
-
return null;
|
|
2644
|
-
},
|
|
2645
|
-
estimate: noWasteModel,
|
|
2646
|
-
}),
|
|
2647
3126
|
defineConfigDetector({
|
|
2648
3127
|
type: 'configAudit', order: 121, fixEffort: 'config', version: 1, inScorecard: false,
|
|
2649
3128
|
emits: ['configAudit'],
|
|
2650
3129
|
docAnchor: '#config-autoscale-bounds', thresholds: {}, property: 'spark.dynamicAllocation.maxExecutors',
|
|
2651
3130
|
detect(target) {
|
|
2652
|
-
const app = target.app; const
|
|
3131
|
+
const app = target.app; const res = app?.resources ?? null;
|
|
2653
3132
|
if (res?.dynamicAllocationEnabled !== true) return null;
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
impactBand: 'critical', metric: 'config', valueText: `${minN} > ${maxN}`,
|
|
2660
|
-
recommendation: `spark.dynamicAllocation.minExecutors (${minN}) exceeds maxExecutors (${maxN}): set min ≤ max.`,
|
|
2661
|
-
remediation: [decreaseConf('spark.dynamicAllocation.minExecutors', maxN)],
|
|
2662
|
-
};
|
|
2663
|
-
}
|
|
2664
|
-
if (maxN == null) {
|
|
3133
|
+
// minExecutors > maxExecutors is not audited: ExecutorAllocationManager.validateSettings throws on it
|
|
3134
|
+
// at startup (v3.5.0 lines 196-199), so no run that wrote an event log has inverted bounds.
|
|
3135
|
+
// Unbounded is the effective maxExecutors being Spark's default (Int.MaxValue) or that value set.
|
|
3136
|
+
const maxN = Number.parseInt(effectiveConf(app, 'spark.dynamicAllocation.maxExecutors') ?? '', 10);
|
|
3137
|
+
if (!Number.isFinite(maxN) || maxN >= UNBOUNDED_EXECUTORS) {
|
|
2665
3138
|
return {
|
|
2666
3139
|
type: 'configAudit', property: 'spark.dynamicAllocation.maxExecutors',
|
|
2667
3140
|
impactBand: 'info', metric: 'config', valueText: '(unset)',
|
|
@@ -2680,7 +3153,13 @@ export const DETECTORS = [
|
|
|
2680
3153
|
detect(target) {
|
|
2681
3154
|
const app = target.app; const config = app?.config ?? {}; const res = app?.resources ?? null;
|
|
2682
3155
|
if (Object.keys(config).length === 0) return null;
|
|
2683
|
-
|
|
3156
|
+
// spark.serializer does not serialize SQL rows (shuffles and caches use UnsafeRow and the columnar
|
|
3157
|
+
// cache serializer), so the note only applies to a run that ran stages outside any SQL execution.
|
|
3158
|
+
// Without the stages there is no evidence of RDD work and no claim.
|
|
3159
|
+
if (!hasStageOutsideSql(target.stages)) return null;
|
|
3160
|
+
// The serializer the run set; Spark's own default (JavaSerializer) is reported as the default.
|
|
3161
|
+
const effective = effectiveSparkConf({ sparkVersion: app?.sparkVersion, properties: config }, 'spark.serializer');
|
|
3162
|
+
const ser = res?.serializer ?? (effective?.source === 'default' ? null : effective?.value ?? null);
|
|
2684
3163
|
const isKryo = typeof ser === 'string' && /kryo/i.test(ser);
|
|
2685
3164
|
if (isKryo) return null;
|
|
2686
3165
|
return {
|
|
@@ -2701,26 +3180,25 @@ export const DETECTORS = [
|
|
|
2701
3180
|
const memMB = res?.executor?.memoryMB ?? null;
|
|
2702
3181
|
const ovMB = res?.executor?.memoryOverheadMB ?? null;
|
|
2703
3182
|
if (memMB == null || ovMB == null) return null;
|
|
2704
|
-
const
|
|
3183
|
+
const { minMB, factor } = defaultOverheadSettings(target.app, thresholds);
|
|
3184
|
+
const floor = Math.max(minMB, Math.round(memMB * factor));
|
|
2705
3185
|
if (ovMB >= floor) return null;
|
|
2706
3186
|
return {
|
|
2707
3187
|
type: 'configAudit', property: 'spark.executor.memoryOverhead',
|
|
2708
3188
|
impactBand: 'info', metric: 'config', valueText: `${ovMB} MiB`,
|
|
2709
|
-
recommendation: `Executor memoryOverhead (${ovMB} MiB) is below
|
|
3189
|
+
recommendation: `Executor memoryOverhead (${ovMB} MiB) is below the ${floor} MiB Spark would have defaulted to (max of ${minMB} MiB or ${Math.round(factor * 1000) / 10}% of executor memory): raise it to avoid off-heap OOM-kills.`,
|
|
2710
3190
|
remediation: [increaseConf('spark.executor.memoryOverhead', `${floor}m`)],
|
|
2711
3191
|
};
|
|
2712
3192
|
},
|
|
2713
3193
|
estimate: noWasteModel,
|
|
2714
3194
|
}),
|
|
2715
|
-
// ── Plan-metric entries (scope:'sql') ────────────────────────────────────
|
|
2716
3195
|
defineSqlDetector({
|
|
2717
3196
|
type: 'duplicatePlanSubtree', order: 130, fixEffort: 'code', version: 2,
|
|
2718
3197
|
emits: ['duplicatePlanSubtree'],
|
|
2719
3198
|
docAnchor: '#bottleneck-duplicate-plan-subtree',
|
|
2720
3199
|
// stageFloorPct: the 0.5% runtime floor. The claim counts at most each linked stage's own
|
|
2721
3200
|
// task-active time, so a repeat whose stages together lasted less than this share of the run
|
|
2722
|
-
//
|
|
2723
|
-
// floor is why they're dropped. A repeat with no linked stage time is kept.
|
|
3201
|
+
// would grade info. A repeat with no linked stage time is kept.
|
|
2724
3202
|
thresholds: { minSubtreeSize: 3, minOccurrences: 2, stageFloorPct: 0.005 },
|
|
2725
3203
|
detect(sqlExec, ctx, thresholds) {
|
|
2726
3204
|
if (!sqlExec.planTree) return null;
|
|
@@ -2743,16 +3221,15 @@ export const DETECTORS = [
|
|
|
2743
3221
|
if (runMs != null && stagesMs > 0 && stagesMs < runMs * thresholds.stageFloorPct) return null;
|
|
2744
3222
|
const occurrencesIdentical = occurrencesHaveIdenticalDetails(g.nodes);
|
|
2745
3223
|
const stageShares = stageOperatorShares(nodes, operatorsByStage);
|
|
2746
|
-
// resolvePlanTree always sets id
|
|
3224
|
+
// resolvePlanTree always sets id, so the `!` here and below is safe.
|
|
2747
3225
|
const planNodeIds = nodes.map((n) => n.id ).filter(Boolean);
|
|
2748
3226
|
const detail = duplicateSubtreeDetail({ ...g, value: g.occurrences });
|
|
2749
3227
|
const differing = occurrencesIdentical ? '' : ` ${DUPLICATE_SUBTREE_DIFFERING_NOTE}`;
|
|
2750
3228
|
return {
|
|
2751
3229
|
type: 'duplicatePlanSubtree', executionId: sqlExec.id, stageIds, planNodeIds,
|
|
2752
3230
|
stageShares, occurrencesIdentical,
|
|
2753
|
-
//
|
|
2754
|
-
//
|
|
2755
|
-
// known to be recomputed), and neither does a subtree with no stage of its own.
|
|
3231
|
+
// Repeats with differing details get no estimate (nothing is known to be recomputed),
|
|
3232
|
+
// and neither does a subtree with no stage of its own, so this fallback band stands.
|
|
2756
3233
|
impactBand: occurrencesIdentical && Object.keys(stageShares).length > 0 ? 'warning' : 'info',
|
|
2757
3234
|
metric: 'subtreeOccurrences', value: g.occurrences,
|
|
2758
3235
|
rootName: g.rootName, subtreeSize: g.subtreeSize, sampleRelation: g.sampleRelation,
|
|
@@ -2772,7 +3249,7 @@ export const DETECTORS = [
|
|
|
2772
3249
|
// detect() reports subtreeOccurrences >= 2. Only repeats past the first are redundant:
|
|
2773
3250
|
// computing the subtree once is real work, so waste is (occurrences-1)/occurrences of the stages' time.
|
|
2774
3251
|
const occurrences = typeof finding.value === 'number' ? finding.value : 0;
|
|
2775
|
-
//
|
|
3252
|
+
// One occurrence repeats nothing: reached only with minOccurrences tuned below 2 or a malformed value.
|
|
2776
3253
|
if (occurrences < 2) return costOnly('none');
|
|
2777
3254
|
// Same-shaped repeats whose details differ compute different data: nothing is known to be
|
|
2778
3255
|
// recomputed, so there is no time to claim.
|
|
@@ -2788,8 +3265,8 @@ export const DETECTORS = [
|
|
|
2788
3265
|
const s = ctx.stages.get(id);
|
|
2789
3266
|
const share = shares ? (shares[id] ?? 0) : 1;
|
|
2790
3267
|
if (s && share > 0) {
|
|
2791
|
-
// Time with tasks running, not submit-to-complete: a stage
|
|
2792
|
-
//
|
|
3268
|
+
// Time with tasks running, not submit-to-complete: a stage waiting for cores isn't
|
|
3269
|
+
// recomputing anything.
|
|
2793
3270
|
const activeMs = s.taskActiveMs ?? Math.max(0, (s.completedAt ?? 0) - (s.submittedAt ?? 0));
|
|
2794
3271
|
wasteMsByStage.set(id, activeMs * share * redundantFraction);
|
|
2795
3272
|
}
|
|
@@ -2847,8 +3324,7 @@ export const DETECTORS = [
|
|
|
2847
3324
|
estimate(finding, ctx) {
|
|
2848
3325
|
const fileMs = ((finding.fileCount ) ?? 0) * FILE_OPEN_OVERHEAD_MS;
|
|
2849
3326
|
// A read's files are opened by the scan's tasks, in parallel: spread the per-file cost over
|
|
2850
|
-
// the most tasks its stages ran at once
|
|
2851
|
-
// stage that ran 314 tasks at once). A write keeps the serial sum: the job commit moves each
|
|
3327
|
+
// the most tasks its stages ran at once. A write keeps the serial sum: the job commit moves each
|
|
2852
3328
|
// output file on the driver, one after another.
|
|
2853
3329
|
const stageIds = finding.stageIds ;
|
|
2854
3330
|
let slots = 1;
|
|
@@ -2861,50 +3337,72 @@ export const DETECTORS = [
|
|
|
2861
3337
|
defineSqlDetector({
|
|
2862
3338
|
// Entry-level type is an identifier only; it never appears on an emitted finding. Findings
|
|
2863
3339
|
// carry 'underBroadcast'/'overBroadcast' since one shared plan-walk covers both
|
|
2864
|
-
// opposite-direction rules
|
|
2865
|
-
type: 'broadcastSizing', order: 132, fixEffort: 'config', version:
|
|
3340
|
+
// opposite-direction rules.
|
|
3341
|
+
type: 'broadcastSizing', order: 132, fixEffort: 'config', version: 3,
|
|
2866
3342
|
// Listed over-first: the two share order 132, and this list order is their display tie-break.
|
|
2867
3343
|
emits: ['overBroadcast', 'underBroadcast'],
|
|
2868
3344
|
docAnchor: '#bottleneck-broadcast-sizing',
|
|
2869
3345
|
thresholds: {
|
|
2870
|
-
broadcastTiers: [10 * MB, 100 * MB, GB
|
|
2871
|
-
comparisonTiers: [10 * GB, 300 * GB
|
|
3346
|
+
broadcastTiers: [10 * MB, 100 * MB, GB],
|
|
3347
|
+
comparisonTiers: [10 * GB, 300 * GB],
|
|
2872
3348
|
overBroadcastBytes: GB,
|
|
3349
|
+
minSmallerSideBytes: MB,
|
|
2873
3350
|
},
|
|
2874
3351
|
detect(sqlExec, ctx, thresholds) {
|
|
2875
3352
|
if (!sqlExec.planTree) return null;
|
|
2876
|
-
const { broadcastTiers, comparisonTiers, overBroadcastBytes } = thresholds;
|
|
3353
|
+
const { broadcastTiers, comparisonTiers, overBroadcastBytes, minSmallerSideBytes } = thresholds;
|
|
2877
3354
|
const fallbackStageIds = stageIdsForSqlExec(sqlExec.id, ctx.stages);
|
|
3355
|
+
const app = queryApp(ctx.app, sqlExec.modifiedConfigs);
|
|
3356
|
+
const thresholdsFor = (adaptive ) => {
|
|
3357
|
+
const key = broadcastThresholdKey(app, adaptive);
|
|
3358
|
+
// A broadcast planned up front is admitted by the static threshold, one AQE converted at runtime
|
|
3359
|
+
// by the adaptive one, so an existing broadcast is judged against each that applied.
|
|
3360
|
+
const admittingKeys = key === BROADCAST_THRESHOLD_KEY ? [key] : [BROADCAST_THRESHOLD_KEY, key];
|
|
3361
|
+
return {
|
|
3362
|
+
key, threshold: effectiveBroadcastThreshold(app, key),
|
|
3363
|
+
admittingThresholds: admittingKeys.map((k) => ({ key: k, threshold: effectiveBroadcastThreshold(app, k) })),
|
|
3364
|
+
};
|
|
3365
|
+
};
|
|
3366
|
+
const staticThresholds = thresholdsFor(false);
|
|
3367
|
+
const adaptiveThresholds = thresholdsFor(true);
|
|
3368
|
+
// AdaptiveSparkPlan need not be the root: Spark 3.2-3.3 wraps only a write command's child.
|
|
3369
|
+
const underAdaptive = new Set ();
|
|
2878
3370
|
const out = [];
|
|
2879
|
-
walkPlanTree(sqlExec.planTree, (node) => {
|
|
2880
|
-
if (node.name === '
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
const
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
const
|
|
3371
|
+
walkPlanTree(sqlExec.planTree, (node, parent) => {
|
|
3372
|
+
if (node.name === 'AdaptiveSparkPlan' || (parent != null && underAdaptive.has(parent))) underAdaptive.add(node);
|
|
3373
|
+
const adaptive = underAdaptive.has(node);
|
|
3374
|
+
const { key, threshold, admittingThresholds } = adaptive ? adaptiveThresholds : staticThresholds;
|
|
3375
|
+
const candidate = node.name === 'SortMergeJoin' ? broadcastCandidate(node) : null;
|
|
3376
|
+
if (candidate) {
|
|
3377
|
+
const buildBytes = shuffleBytes(candidate.build);
|
|
3378
|
+
const otherBytes = shuffleBytes(candidate.stream);
|
|
3379
|
+
// Below the floor a broadcast saves nothing measurable, nor does broadcasting the larger side;
|
|
3380
|
+
// above the over-broadcast limit it would be flagged there instead.
|
|
3381
|
+
if (buildBytes >= minSmallerSideBytes && buildBytes <= overBroadcastBytes && buildBytes <= otherBytes) {
|
|
3382
|
+
const broadcastThreshold = threshold != null && threshold < 0 ? 'disabled'
|
|
3383
|
+
: threshold != null && threshold >= buildBytes ? 'notLimiting' : 'limits';
|
|
3384
|
+
const withinTiers = buildBytes < broadcastTiers[0]
|
|
3385
|
+
|| (buildBytes < broadcastTiers[1] && otherBytes > comparisonTiers[0])
|
|
3386
|
+
|| (buildBytes < broadcastTiers[2] && otherBytes > comparisonTiers[1]);
|
|
3387
|
+
if (broadcastThreshold === 'notLimiting' || withinTiers) {
|
|
3388
|
+
const contributors = [candidate.build, candidate.stream];
|
|
3389
|
+
const { joinType, buildSide } = candidate;
|
|
3390
|
+
const subject = `The ${buildSide} input to this ${joinType} Sort Merge Join (${formatBytes(buildBytes)})`;
|
|
3391
|
+
const otherSide = ` (the other side is ${formatBytes(otherBytes)})`;
|
|
2898
3392
|
out.push({
|
|
2899
3393
|
type: 'underBroadcast', executionId: sqlExec.id, stageIds: unionStageIds(contributors, fallbackStageIds),
|
|
2900
|
-
// resolvePlanTree always sets id; safe downstream of it.
|
|
2901
3394
|
planNodeIds: contributors.map((n) => n.id ).filter(Boolean),
|
|
2902
|
-
impactBand: 'info', metric: 'smallerSideBytes',
|
|
2903
|
-
|
|
3395
|
+
impactBand: 'info', metric: 'smallerSideBytes',
|
|
3396
|
+
value: buildBytes, largerSideBytes: otherBytes,
|
|
3397
|
+
joinType, buildSide, broadcastThreshold,
|
|
2904
3398
|
recommendation: broadcastThreshold === 'notLimiting'
|
|
2905
|
-
?
|
|
2906
|
-
|
|
2907
|
-
|
|
3399
|
+
? `${subject} is under the effective ${key} (${formatBytes(threshold )}) yet was not broadcast${otherSide}, so the threshold is not what stopped it: ${adaptive
|
|
3400
|
+
? `a join hint, a shuffle that had already run, or a low share of non-empty partitions (below ${NON_EMPTY_PARTITION_RATIO_KEY}) usually is. Consider a broadcast() hint or lowering ${NON_EMPTY_PARTITION_RATIO_KEY}.`
|
|
3401
|
+
: 'a join hint, missing table statistics, or a shuffle that had already run usually is. Consider a broadcast() hint or collecting statistics (ANALYZE TABLE).'}`
|
|
3402
|
+
: broadcastThreshold === 'disabled'
|
|
3403
|
+
? `${subject} was not broadcast${otherSide} because automatic broadcast is disabled (${key}=-1): this could have been a broadcast join. Set ${key} to a positive size or add a broadcast() hint.`
|
|
3404
|
+
: `${subject} is over the effective ${key} (${threshold == null ? 'not a size' : formatBytes(threshold)}) but small enough to broadcast${otherSide}: this could have been a broadcast join. Consider a broadcast() hint or raising ${key}.`,
|
|
3405
|
+
remediation: broadcastThreshold === 'notLimiting' ? [] : [increaseConf(key)],
|
|
2908
3406
|
});
|
|
2909
3407
|
}
|
|
2910
3408
|
}
|
|
@@ -2912,27 +3410,27 @@ export const DETECTORS = [
|
|
|
2912
3410
|
if (isBroadcastExchangeNode(node.name)) {
|
|
2913
3411
|
const m = (node.metrics ?? []).find(x => x.name === 'data size');
|
|
2914
3412
|
if (m && m.value > overBroadcastBytes) {
|
|
2915
|
-
// BroadcastExchange's own metrics are driver-computed and never on
|
|
2916
|
-
//
|
|
2917
|
-
// metrics, so only the child unions in.
|
|
3413
|
+
// BroadcastExchange's own metrics are driver-computed and never on a TaskEnd, so only
|
|
3414
|
+
// its child, which carries the executor-side metrics, contributes stage ids.
|
|
2918
3415
|
const child = (node.children ?? [])[0];
|
|
2919
|
-
// A threshold below the broadcast cannot have admitted it,
|
|
2920
|
-
const
|
|
2921
|
-
const autoBroadcastOff = threshold != null && threshold < 0;
|
|
2922
|
-
const hintForced =
|
|
3416
|
+
// A threshold below the broadcast cannot have admitted it: with none admitting it, a hint forced it.
|
|
3417
|
+
const admitted = admittingThresholds.filter((t) => t.threshold == null || t.threshold >= m.value);
|
|
3418
|
+
const autoBroadcastOff = admittingThresholds.every((t) => t.threshold != null && t.threshold < 0);
|
|
3419
|
+
const hintForced = admitted.length === 0;
|
|
2923
3420
|
const broadcastThreshold = autoBroadcastOff ? 'disabled' : hintForced ? 'notLimiting' : 'limits';
|
|
3421
|
+
const below = admittingThresholds.map((t) => `${t.key} (${t.threshold < 0 ? 'disabled' : formatBytes(t.threshold )})`).join(' and ');
|
|
3422
|
+
const admittedKeys = admitted.map((t) => t.key);
|
|
2924
3423
|
out.push({
|
|
2925
3424
|
type: 'overBroadcast', executionId: sqlExec.id,
|
|
2926
3425
|
stageIds: unionStageIds(child ? [child] : [], fallbackStageIds),
|
|
2927
|
-
// resolvePlanTree always sets id; safe downstream of it.
|
|
2928
3426
|
planNodeIds: [node.id ].filter(Boolean),
|
|
2929
3427
|
impactBand: 'warning', metric: 'broadcastBytes', value: m.value, broadcastThreshold,
|
|
2930
3428
|
recommendation: autoBroadcastOff
|
|
2931
3429
|
? `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: automatic broadcast is already disabled, so remove the broadcast() hint that forced it.`
|
|
2932
3430
|
: hintForced
|
|
2933
|
-
? `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold:
|
|
2934
|
-
: `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: check for a misapplied broadcast hint or a misconfigured
|
|
2935
|
-
remediation:
|
|
3431
|
+
? `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: ${below} ${admittingThresholds.length > 1 ? 'are' : 'is'} below it, so a broadcast() hint forced it: remove the hint.`
|
|
3432
|
+
: `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: check for a misapplied broadcast hint or a misconfigured ${admittedKeys.join(' or ')}.`,
|
|
3433
|
+
remediation: admittedKeys.map((k) => decreaseConf(k)),
|
|
2936
3434
|
});
|
|
2937
3435
|
}
|
|
2938
3436
|
}
|
|
@@ -2941,15 +3439,138 @@ export const DETECTORS = [
|
|
|
2941
3439
|
},
|
|
2942
3440
|
estimate(finding, ctx) {
|
|
2943
3441
|
// Both finding types carry bytes as `value`: overBroadcast's broadcastBytes, underBroadcast's
|
|
2944
|
-
// smallerSideBytes (
|
|
3442
|
+
// smallerSideBytes (its build side), each priced as one broadcast transfer.
|
|
2945
3443
|
const wasteMs = (((finding.value ) ?? 0) / BROADCAST_BANDWIDTH_BPS) * 1000;
|
|
2946
3444
|
return stageMappableWasteOrCostOnly(wasteMs, finding.stageIds , ctx);
|
|
2947
3445
|
},
|
|
2948
3446
|
}),
|
|
3447
|
+
defineSqlDetector({
|
|
3448
|
+
type: 'pythonUdf', order: 134, fixEffort: 'code', version: 1,
|
|
3449
|
+
emits: ['pythonUdf'],
|
|
3450
|
+
docAnchor: '#pyspark',
|
|
3451
|
+
// Unvalidated against a real workload (no corpus or private log runs a Python UDF): set high so
|
|
3452
|
+
// only a stage that moved a lot of data for a long time is flagged.
|
|
3453
|
+
thresholds: { minBytesSent: 64 * MB, minStageMs: 30_000 },
|
|
3454
|
+
detect(sqlExec, ctx, thresholds) {
|
|
3455
|
+
if (!sqlExec.planTree) return null;
|
|
3456
|
+
const { minBytesSent, minStageMs } = thresholds;
|
|
3457
|
+
// BatchEvalPython runs a row-at-a-time UDF; ArrowEvalPython (an Arrow-optimized or pandas UDF)
|
|
3458
|
+
// is already the fix. Both Python byte metrics are executor-side; a plan that reports no sent
|
|
3459
|
+
// bytes is skipped.
|
|
3460
|
+
const nodes = [];
|
|
3461
|
+
let sentBytes = 0;
|
|
3462
|
+
let returnedBytes = 0;
|
|
3463
|
+
let reportsReturned = false;
|
|
3464
|
+
walkPlanTree(sqlExec.planTree, (node) => {
|
|
3465
|
+
if (!isBatchEvalPythonNode(node)) return;
|
|
3466
|
+
const sent = node.metrics?.find((m) => m.name === PYTHON_DATA_SENT)?.value;
|
|
3467
|
+
if (sent == null || !(node.stageIds?.length)) return;
|
|
3468
|
+
nodes.push(node);
|
|
3469
|
+
sentBytes += sent;
|
|
3470
|
+
const returned = node.metrics?.find((m) => m.name === PYTHON_DATA_RETURNED)?.value;
|
|
3471
|
+
if (returned != null) { returnedBytes += returned; reportsReturned = true; }
|
|
3472
|
+
});
|
|
3473
|
+
if (nodes.length === 0 || !(sentBytes >= minBytesSent)) return null;
|
|
3474
|
+
const stageIds = unionStageIds(nodes, []);
|
|
3475
|
+
const stageDurationMs = stageIds.reduce((sum, id) => {
|
|
3476
|
+
const stage = ctx.stages.get(id);
|
|
3477
|
+
return sum + Math.max(0, (stage?.completedAt ?? 0) - (stage?.submittedAt ?? 0));
|
|
3478
|
+
}, 0);
|
|
3479
|
+
if (!(stageDurationMs >= minStageMs)) return null;
|
|
3480
|
+
|
|
3481
|
+
const arrowFix = pythonUdfArrowFix(queryApp(ctx.app, sqlExec.modifiedConfigs));
|
|
3482
|
+
const traffic = `sent ${formatBytes(sentBytes)} to Python workers${reportsReturned ? ` and received ${formatBytes(returnedBytes)} back` : ''}`;
|
|
3483
|
+
return [{
|
|
3484
|
+
type: 'pythonUdf', executionId: sqlExec.id, stageIds, planNodeIds: nodes.flatMap(n => (n.id ? [n.id] : [])),
|
|
3485
|
+
impactBand: 'info',
|
|
3486
|
+
metric: 'dataSentBytes', value: sentBytes,
|
|
3487
|
+
dataSentBytes: sentBytes, dataReturnedBytes: reportsReturned ? returnedBytes : null, stageDurationMs,
|
|
3488
|
+
recommendation: `Row-at-a-time Python UDFs (BatchEvalPython) ${traffic} over ${formatDuration(stageDurationMs)} of stage time: ${arrowFix.text}`,
|
|
3489
|
+
remediation: arrowFix.remediation,
|
|
3490
|
+
}];
|
|
3491
|
+
},
|
|
3492
|
+
estimate: noWasteModel,
|
|
3493
|
+
}),
|
|
3494
|
+
defineSqlDetector({
|
|
3495
|
+
type: 'nestedLoopJoin', order: 133, fixEffort: 'code', version: 1,
|
|
3496
|
+
emits: ['nestedLoopJoin'],
|
|
3497
|
+
docAnchor: '#joins',
|
|
3498
|
+
// minOutputRows: below it the join is cheap whatever its shape. minExpansion: a
|
|
3499
|
+
// BroadcastNestedLoopJoin's output must be this many times its larger input, so a join that
|
|
3500
|
+
// keeps its rows, or a cross join against a one-row side, is not an explosion. The stage time the
|
|
3501
|
+
// finding is graded on decides how much it matters.
|
|
3502
|
+
thresholds: { minOutputRows: 1_000_000, minExpansion: 10 },
|
|
3503
|
+
detect(sqlExec, _ctx, thresholds) {
|
|
3504
|
+
if (!sqlExec.planTree) return null;
|
|
3505
|
+
const out = [];
|
|
3506
|
+
walkPlanTree(sqlExec.planTree, (node) => {
|
|
3507
|
+
const shape = parseNestedLoopJoin(node);
|
|
3508
|
+
if (!shape || node.children.length !== 2) return;
|
|
3509
|
+
const outputRows = ownOutputRows(node);
|
|
3510
|
+
if (outputRows == null || outputRows < thresholds.minOutputRows) return;
|
|
3511
|
+
// A CartesianProduct re-reads its inputs (UnsafeCartesianRDD in joins/CartesianProductExec
|
|
3512
|
+
// computes every left and right partition pair, so each input partition is read once per
|
|
3513
|
+
// partition of the other side), so the executors' input counts exceed the real row counts and cannot be compared with the output. It runs only when neither side can
|
|
3514
|
+
// be broadcast, and its output is the product of the inputs, so size alone qualifies it.
|
|
3515
|
+
const counted = shape.operator === 'CartesianProduct';
|
|
3516
|
+
const leftRows = counted ? null : outputRowsOf(node.children[0]);
|
|
3517
|
+
const rightRows = counted ? null : outputRowsOf(node.children[1]);
|
|
3518
|
+
let expansion = null;
|
|
3519
|
+
if (!counted) {
|
|
3520
|
+
// Without both input counts "far above both inputs" cannot be shown.
|
|
3521
|
+
if (leftRows == null || rightRows == null) return;
|
|
3522
|
+
expansion = outputRows / Math.max(leftRows, rightRows, 1);
|
|
3523
|
+
if (expansion < thresholds.minExpansion) return;
|
|
3524
|
+
}
|
|
3525
|
+
// Only the stages that run the join: the whole execution's stages would claim time the
|
|
3526
|
+
// join never spent.
|
|
3527
|
+
const stageIds = unionStageIds([node], []);
|
|
3528
|
+
const advice = shape.condition == null
|
|
3529
|
+
? 'Confirm the cross join is intended, or add a join key so the rows are matched instead of multiplied.'
|
|
3530
|
+
: 'Add an equi-join key so Spark can use a hash or sort-merge join; for a range condition, bucket the range and join on the bucket as well.';
|
|
3531
|
+
const on = shape.condition == null ? '' : ` on ${shape.condition}`;
|
|
3532
|
+
out.push({
|
|
3533
|
+
type: 'nestedLoopJoin', executionId: sqlExec.id, stageIds,
|
|
3534
|
+
planNodeIds: [node.id ].filter(Boolean),
|
|
3535
|
+
impactBand: 'info',
|
|
3536
|
+
metric: 'outputRows', value: outputRows,
|
|
3537
|
+
nodeName: shape.operator, joinType: shape.joinType, condition: shape.condition,
|
|
3538
|
+
outputRows, leftRows, rightRows,
|
|
3539
|
+
confidence: 'medium',
|
|
3540
|
+
validationRequired: 'Row counts are the executors\' measured values; the time graded is the whole stage that runs the join, which also covers its other operators.',
|
|
3541
|
+
recommendation: `${shape.operator}${on} produced ${outputRows.toLocaleString('en-US')} rows${expansion == null
|
|
3542
|
+
? shape.condition == null
|
|
3543
|
+
? ', every left row paired with every right row.'
|
|
3544
|
+
// The condition filtered the pairings: the output is the rows it kept, not the product.
|
|
3545
|
+
: ': the pairs its condition kept from every left and right pairing it compared.'
|
|
3546
|
+
: ` from ${leftRows .toLocaleString('en-US')} and ${rightRows .toLocaleString('en-US')} input rows (${Math.round(expansion).toLocaleString('en-US')}x the larger side).`} ${advice}`,
|
|
3547
|
+
remediation: [codeFix(advice)],
|
|
3548
|
+
});
|
|
3549
|
+
});
|
|
3550
|
+
return out.length > 0 ? out : null;
|
|
3551
|
+
},
|
|
3552
|
+
estimate(finding, ctx) {
|
|
3553
|
+
const stageIds = finding.stageIds ;
|
|
3554
|
+
if (!stageIds || stageIds.length === 0) return costOnly('none');
|
|
3555
|
+
// The join's cost is the time of the stages that run it: a nested-loop join is O(n x m)
|
|
3556
|
+
// inside them. Time with tasks running, not submit-to-complete (a stage waiting for cores
|
|
3557
|
+
// is not looping).
|
|
3558
|
+
const wasteMsByStage = new Map ();
|
|
3559
|
+
for (const id of stageIds) {
|
|
3560
|
+
const s = ctx.stages.get(id);
|
|
3561
|
+
if (s) wasteMsByStage.set(id, s.taskActiveMs ?? Math.max(0, (s.completedAt ?? 0) - (s.submittedAt ?? 0)));
|
|
3562
|
+
}
|
|
3563
|
+
if (wasteMsByStage.size === 0) return costOnly('none');
|
|
3564
|
+
const totalMs = [...wasteMsByStage.values()].reduce((sum, ms) => sum + ms, 0);
|
|
3565
|
+
const rawWaste = { value: totalMs, unit: 'ms' };
|
|
3566
|
+
return multiStageImpact([...wasteMsByStage.keys()], wasteMsByStage, ctx, 'modeled', rawWaste)
|
|
3567
|
+
?? costOnly('modeled', rawWaste);
|
|
3568
|
+
},
|
|
3569
|
+
}),
|
|
2949
3570
|
] ;
|
|
2950
3571
|
|
|
2951
3572
|
/** The entry that emits each finding type: the one whose estimate prices it and whose thresholds
|
|
2952
|
-
* and order describe it. Several entries can emit one type (the
|
|
3573
|
+
* and order describe it. Several entries can emit one type (the three configAudit audits), and the
|
|
2953
3574
|
* first declared wins. */
|
|
2954
3575
|
export const ENTRY_BY_TYPE = (() => {
|
|
2955
3576
|
const byType = new Map ();
|
|
@@ -2989,3 +3610,9 @@ export const ENTRY_BY_TYPE = (() => {
|
|
|
2989
3610
|
|
|
2990
3611
|
|
|
2991
3612
|
|
|
3613
|
+
|
|
3614
|
+
/** skew's thresholds for a run: its defaults with the user's `skew` overrides merged in. The one
|
|
3615
|
+
* resolution analyze() hands to both skew (through withThresholds) and straggler (DetectorCtx). */
|
|
3616
|
+
export function skewThresholdsFor(overrides ) {
|
|
3617
|
+
return mergeThresholds('skew', ENTRY_BY_TYPE.get('skew') .thresholds, overrides?.skew ?? {}) ;
|
|
3618
|
+
}
|