Skip to content

[SPARK-48290][CORE] Record skewed shuffle block sizes by default so that AQE can detect skew - #57528

Closed
Vivek1106-04 wants to merge 7 commits into
apache:masterfrom
Vivek1106-04:SPARK-48290-skew-mapstatus
Closed

Vivek1106-04 wants to merge 7 commits into
apache:masterfrom
Vivek1106-04:SPARK-48290-skew-mapstatus

Conversation

@Vivek1106-04

@Vivek1106-04 Vivek1106-04 commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Once a shuffle has more than spark.shuffle.minNumPartitionsToHighlyCompress (2000) partitions, map statuses switch to HighlyCompressedMapStatus, which keeps exact sizes only for blocks above spark.shuffle.accurateBlockThreshold (100MB) and reports the average block size for everything else. MapOutputTracker.getStatistics sums those per reduce id, so AQE ends up looking at a flat distribution.

spark.shuffle.accurateBlockSkewedFactor, added by SPARK-36967, keeps skewed block sizes accurate, but it ships disabled (-1.0) even though its own documentation recommends setting it to the same value as spark.sql.adaptive.skewJoin.skewedPartitionFactor. Enabling it fully sorted the block sizes in every map task, which is presumably why it was left off.

This PR:

  1. replaces the sort in HighlyCompressedMapStatus.apply with selection of the only two order statistics it needs (the median, and the K-th largest size), using a new in-place Utils.nthSmallest plus Utils.medianInPlace. This is O(numPartitions) on average rather than O(numPartitions log numPartitions), with the same result. nthSmallest is an introselect: the pivot is the median of the first, middle and last element, and it sorts the range still under consideration once the ranges have stopped shrinking quickly enough, which bounds the worst case at O(numPartitions log numPartitions) even for adversarial inputs such as an organ pipe distribution;
  2. enforces spark.shuffle.maxAccurateSkewedBlockNumber as an actual cardinality bound. Previously only the size of the cutoff block bounded the recording, so blocks tied at that size were all recorded. The blocks strictly larger than the cutoff are now counted up front, and the remainder of the budget admits tied blocks in block index order, which keeps the map status deterministic. Blocks at or above spark.shuffle.accurateBlockThreshold are still always recorded, as they were before;
  3. changes the default of spark.shuffle.accurateBlockSkewedFactor from -1.0 to 5.0, matching spark.sql.adaptive.skewJoin.skewedPartitionFactor. -1.0 still disables the feature;
  4. updates the config documentation to say what leaving it off actually costs.

Why are the changes needed?

Skew join optimization silently stops working above 2000 shuffle partitions. In the reporter's case a 263GB partition spread over 10335 mappers is only ~25MB per block, far below the 100MB accurate threshold, so every block is reported as the average and AQE logs median size == max size == min size and finds no skew. Lowering spark.shuffle.accurateBlockThreshold does not help, because what matters is the per-block size rather than the per-partition total.

Reproduced on master with a join where 95% of the rows land on one key, varying only spark.sql.shuffle.partitions:

spark.sql.shuffle.partitions extra configuration skew detected
1999 (defaults) yes, SortMergeJoin(skew=true)
2001 (defaults) no
4000 (defaults) no
2001 spark.shuffle.accurateBlockSkewedFactor=5.0 yes
2001 spark.shuffle.minNumPartitionsToHighlyCompress=100000 yes

Either knob restores detection on its own, which locates the problem in the map status compression rather than in AQE.

The cost of the change, measured with the benchmark added here (per map task, Apple M4, JDK 21):

shuffle partitions skewed sizes not recorded recorded, skewed distribution recorded, organ pipe distribution
2048 6.4us 17.3us 23.6us
10000 14.7us 56.3us 99.5us
50000 54.6us 309.8us 530.2us

For comparison, the sorting implementation this PR replaces measured 28.8us, 140.7us and 1562.0us on the same machine, so at 50000 partitions the accurate path is now about 5x cheaper than sorting would have been. The organ pipe column is the worst case for the selection, where the introselect fallback kicks in; it stays within 1.7x of the ordinary case and does not grow quadratically.

On the driver side each map status carries at most spark.shuffle.maxAccurateSkewedBlockNumber (100) extra entries of an int and a byte, so about 500 bytes per map task, or ~5MB for a 10000 map task stage.

Does this PR introduce any user-facing change?

Yes. Shuffles with more than spark.shuffle.minNumPartitionsToHighlyCompress partitions now report skewed block sizes accurately by default, so AQE can detect and split skewed partitions where it previously could not. Queries affected by this will change plans (skew joins get split) and should run faster. Setting spark.shuffle.accurateBlockSkewedFactor=-1.0 restores the old behavior.

How was this patch tested?

New tests in MapStatusSuite:

  • a skewed block below spark.shuffle.accurateBlockThreshold is recorded accurately under default configuration, and the average is not inflated by it. Fails on master with 88733 did not equal 1500.
  • at 50000 blocks, 25001 of 1 KiB and 24999 of 8 KiB, exactly spark.shuffle.maxAccurateSkewedBlockNumber blocks are recorded and the rest report the average. Fails without the cardinality bound with 1024 did not equal 4600, because all 24999 tied blocks were recorded and the average collapsed to the size of the small blocks.

New tests in UtilsSuite:

  • Utils.nthSmallest agrees with a full sort over sorted, reverse sorted, constant, organ pipe, duplicate-heavy and random inputs, and its argument validation.
  • Utils.nthSmallest leaves the array partitioned around the returned element. HighlyCompressedMapStatus counts the sizes above the cutoff by scanning only the tail of the array, so it depends on this and not just on the returned value.
  • six selections over a 1048576 element organ pipe complete within a 60 second limit. A quadratic selection needs more than 10^11 comparisons there; the introselect takes 100ms in total.
  • Utils.medianInPlace agrees with Utils.median.

HighlyCompressedMapStatusBenchmark is new, with results generated at 2048, 10000 and 50000 partitions for both distributions.

Existing suites: MapStatusSuite, MapOutputTrackerSuite, ShuffleSuite, UtilsSuite, org.apache.spark.shuffle.sort.* and AdaptiveQueryExecSuite (129 tests) all pass.

End to end, with a locally built assembly and no configuration beyond the SQL level skew join settings, the table above becomes yes / yes / yes at 1999, 2001 and 4000 partitions.

I did not add an AQE level test for the >2000 partition case: spark.shuffle.minNumPartitionsToHighlyCompress is read through a lazy val on the JVM's SparkEnv, so it cannot be lowered from within a suite, and running a query with 2001 shuffle partitions takes about a minute. SPARK-36967 tested this feature at MapStatusSuite level for the same reason. Happy to add one if reviewers would rather pay the runtime.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two default-enabled P1 regressions are reproducible at the current head: cutoff ties bypass the configured skewed-block cap, and midpoint-pivot selection becomes quadratic on valid partition-size distributions. Please see the inline comments for concrete reproductions and suggested fixes.

val sizes = uncompressedSizes.clone()
val medianSize: Long = Utils.medianInPlace(sizes)
val smallestAccurateSize =
Utils.nthSmallest(sizes, totalNumBlocks - maxAccurateSkewedBlockNumber)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P1] Enforce the skewed-block cap when the cutoff has ties

nthSmallest returns the 100th-largest block size, but the classification below records every block with size >= threshold. This means ties at the cutoff bypass spark.shuffle.maxAccurateSkewedBlockNumber.

For example, with the proposed defaults and 50,000 reduce partitions, consider 25,001 blocks of 1 KiB and 24,999 blocks of 8 KiB. The median is 1 KiB, the 100th-largest size is 8 KiB, and the threshold is 8 KiB, so all 24,999 larger blocks are recorded instead of at most 100. writeExternal writes at least five bytes per recorded entry, so a 10,000-map stage produces 1,249,950,000 bytes of uncompressed map-status entries, before the much larger driver-side collection overhead. The base branch's disabled default does not record any of these blocks.

Since this PR enables this path by default, please enforce the actual cardinality bound, including deterministic handling of ties, while preserving mandatory accurate recording above spark.shuffle.accurateBlockThreshold. Please also add a regression test containing more than 100 equal-sized blocks at the cutoff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, and the reproduction is exact. Fixed in 2ced43b.

The cutoff size alone was never a cardinality bound, only a size bound. The blocks strictly larger than the cutoff are now counted up front -- the selection leaves them all in the top-K window, so that count is at most the configured maximum -- and the remainder of the budget admits blocks of exactly the cutoff size, in block index order, so the map status stays deterministic. No strictly larger block is ever dropped in favour of a tied one, since only ties are rationed. Blocks at or above spark.shuffle.accurateBlockThreshold are still recorded unconditionally, so the mandatory path is unchanged.

The regression test is your example verbatim: 50000 partitions, 25001 blocks of 1 KiB and 24999 of 8 KiB, stock defaults. Without the bound it fails with 1024 did not equal 4600 -- all 24999 tied blocks get recorded and avgSize collapses to the size of the small blocks, which is a neat second symptom of the same bug.

while (low < high) {
// The middle element keeps already sorted and reverse sorted inputs, which are both common
// for shuffle block sizes, away from the quadratic worst case.
val pivot = sizes(low + (high - low) / 2)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P1] Bound the worst-case cost of default-enabled quickselect

Always selecting the middle element makes the algorithm quadratic for a valid organ-pipe reducer-size distribution: Array.tabulate(n)(i => math.min(i, n - 1 - i).toLong). Replaying the exact selections made by HighlyCompressedMapStatus produces 805,003 comparisons at 2,048 partitions, 3,174,774 at 4,096, 12,682,753 at 8,192, and 50,455,932 at 16,384. Doubling the number of partitions approximately quadruples the work.

This becomes a production regression because the PR changes spark.shuffle.accurateBlockSkewedFactor from -1.0 to 5.0, making every map task in a qualifying shuffle execute these selections; the base default performs no selection. At 16,384 partitions, a 10,000-map stage would require more than 504 billion comparisons just to construct map statuses.

Please use a worst-case-bounded selection algorithm, or an introspective/randomized pivot with a safe fallback, and add an organ-pipe input to the tests and benchmark.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and thanks for the organ pipe input specifically. Fixed in 2ced43b.

nthSmallest is now an introselect: median-of-three pivot, and it sorts the range still under consideration once the ranges have stopped shrinking for 2 * log2(n) rounds. That bounds the worst case at O(n log n) regardless of the input, rather than relying on the pivot choice being lucky.

One thing I want to be upfront about: I did not reproduce your comparison counts or instrument the post-fix ones. What I added is a time bound on the input you named -- six selections over a 2^20 organ pipe under an explicit 60 second limit, which currently run in 100ms total. Extrapolating your table (50,455,932 at 16384, roughly quadrupling per doubling) puts a quadratic selection at more than 10^11 comparisons there, so the limit is generous enough not to be flaky while still failing decisively on a regression. If you would rather have an actual comparison count asserted against a ceiling, I can add one, though it means an instrumented copy of the loop in test code.

I also added a test asserting that nthSmallest leaves the array partitioned around the returned element, since HighlyCompressedMapStatus now counts the sizes above the cutoff by scanning only the tail of the array and so depends on that ordering, not just on the returned value.

The benchmark now reports the organ pipe distribution alongside the skewed one. At 50000 partitions it is 530us per map task against 310us for the ordinary case -- a 1.7x gap, not a blowup.

@Vivek1106-04
Vivek1106-04 requested a review from sunchao July 27, 2026 03:11

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two independently reproduced P2 issues remain on the current head: mapper-invariant cutoff-tie selection can hide a genuinely skewed reducer, and default-enabled boxed map metadata retains approximately 11.55x the driver heap estimated in the PR. See the inline reproductions.

if (size > skewCutoff) {
isAccurate = true
} else if (numTiedSkewedBlocksToRecord > 0) {
// Ties are broken by block index so that the map status stays deterministic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Rotate cutoff ties across map tasks to avoid hiding skew

The new tie cap always admits the lowest reducer IDs, and each mapper observes the same tied reducers. MapOutputTracker.getStatistics sums each reducer's getSizeForBlock across map tasks, so a tied reducer that loses here is permanently represented by avgSize.

For example, use the defaults with 2,001 reducers and 10,000 map tasks. On every mapper, reducers 0-1899 have 10 KiB blocks and reducers 1900-2000 have 100 KiB blocks. The median is 10 KiB, the cutoff is 100 KiB, and the 100-entry cap always selects reducers 1900-1999. Reducer 2000 instead reports (1900 * 10240 + 102400) / 1901 = 10288 bytes per mapper, so AQE sees 102,880,000 bytes although its real size is 1,024,000,000 bytes. The real partition exceeds the skew threshold max(256 MiB, 5 * 102,880,000) = 514,400,000 bytes, but is never detected or split; the other 100 equally skewed reducers are detected.

Please rotate deterministic cutoff-tie selection using the mapper identity (for example mapTaskId) and add a regression test involving multiple map statuses, while preserving the 100-entry cap.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] The new rotation still leaves genuinely skewed reducers invisible

This remains reproducible at d3be2ba389d6594077219f52be0d5c5919e068a5. The new floorMod(mapTaskId, numTiedSkewedBlocks) advances a 100-entry tied-reducer window by only one position per consecutive mapper. Therefore M mappers cover only 100 + M - 1 tied reducers before wraparound.

Concrete default-configuration reproduction:

  • 2,001 reduce partitions and 10 map tasks.
  • Reducers 0-1880 produce 5 MiB blocks on every mapper.
  • Reducers 1881-2000 produce 50 MiB blocks on every mapper, below the 100 MiB mandatory-accuracy threshold.
  • The accurate-skew cap remains 100 per mapper.

Each status reports avgSize = 5,739,312 bytes for unselected blocks. Reducers 1990-2000 never enter any accurate window, so MapOutputTracker reports 57,393,120 bytes although each reducer actually contains 524,288,000 bytes. AQE computes a skew threshold of max(256 MiB, 5 * 57,393,120) = 286,965,600 bytes, so these genuinely skewed reducers remain unsplit. Overall, 19 of the 120 tied reducers miss the threshold.

There is also a second failure mode even when rotation distributes selections evenly: with 1,801 reducers producing 10 KiB blocks, 200 tied reducers producing 100 KiB blocks, and 10,000 mappers, each tied reducer is recorded on half the mappers. Unselected ties inflate avgSize to 15,087 bytes, so each skewed reducer is reported as 636,320,000 bytes against an AQE threshold of 754,350,000, even though its actual size is 1,024,000,000. All 200 genuinely skewed reducers are missed.

The new regression only covers 101 ties and 2,000 mappers, where almost every tied block is recorded on almost every mapper. Please cover both fewer-mapper/larger-tie groups and fairly sampled larger tie groups, while preserving the per-status cap and AQE visibility.

.version("3.3.0")
.doubleConf
.createWithDefault(-1.0)
.createWithDefault(5.0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Bound retained driver heap before enabling skew recording by default

Changing the default from -1.0 to 5.0 means each qualifying map status can now retain up to 100 additional entries in mutable.Map[Int, Byte], and the driver retains those deserialized map statuses. The PR estimates five bytes per entry (500 bytes per mapper; approximately 5 MB for 10,000 mappers), but those are only the writeExternal payload bytes. The live representation is a Scala mutable.HashMap with nodes, bucket arrays, and typically boxed reducer IDs.

Using the exact Scala 2.13.18 version declared by this head, I measured 10,000 mutable.HashMaps containing 100 reducer IDs in 1900-1999: they retained 57,752,240 additional heap bytes, or 5,775 bytes per mapper, versus the claimed 5,000,000 bytes - an 11.55x difference before cached serialized statuses or broadcast copies. The same distribution projects to approximately 551 MiB of additional retained heap at 100,000 mappers; the base branch's disabled default records none of these subthreshold entries.

Please measure retained MapOutputTracker heap for realistic high-map stages and use compact/primitive-backed storage or otherwise bound the driver-memory cost before enabling this path by default.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@sunchao! Both fixed, The tie break now rotates: the window of blocks tied at the cutoff that a map task records starts at mapTaskId mod numTied and wraps, so a tied reducer is averaged away on roughly one mapper in numTied instead of on all of them — cap unchanged, map status still deterministic, avgSize unaffected since only which blocks are recorded changes, not how many. Your example is the regression test at 2000 map statuses: it asserts each status still records exactly 100 blocks and every one of the 101 tied reducers sums to more than 98% of its real size, and without the rotation it fails at 20576000 was not greater than 2.1986692E8, your arithmetic exactly. On the heap: I reproduced your measurement first — on a real MapOutputTrackerMaster holding a 10000 map task stage, the retained map statuses are 6004 bytes per map task, 57 MiB for the stage, matching your 5775 and 57,752,240, so the five bytes in the PR body were writeExternal payload and I've corrected that claim. hugeBlockSizes is now two parallel primitive arrays sorted by reduce id that getSizeForBlock binary searches, which brings the same measurement to 668 bytes per map task and 6 MiB for the stage, about 63 MiB at 100000 mappers against your projected 551 MiB; the benchmark runs that measurement itself and prints it. Since swapping a hash lookup for a binary search invites the obvious question, I benchmarked reading every block size out of every map status too: 6.5 ns per lookup with the map versus 3.9 ns with the arrays at 50000 partitions, the reduce id no longer being boxed on every get. Two costs worth naming: the serialized statuses for that stage grow from 22 KiB to 27 KiB because each map task now records a different id set and compresses slightly worse, and the rotation does nothing when blocks strictly above the cutoff already fill the budget, since then no tie is recorded on any mapper — that is the cap's semantics rather than the tie break's, so I left it alone, but happy to revisit if you'd rather it were handled.

@Vivek1106-04
Vivek1106-04 force-pushed the SPARK-48290-skew-mapstatus branch from 7adc6da to d3be2ba Compare August 2, 2026 15:53
@Vivek1106-04
Vivek1106-04 requested a review from sunchao August 3, 2026 08:10

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two verified P2 issues remain at d3be2ba: cutoff-tie rotation can still hide skew from AQE, and the new retained-memory regression test never exercises HighlyCompressedMapStatus.

// The driver retains one map status per map task for the lifetime of the shuffle, so the
// accurately recorded sizes must not cost more than a few bytes each. A mutable.Map[Int, Byte]
// costs an order of magnitude more, through its nodes, bucket array and boxed reduce ids.
val smallBlocksLength = 1900

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Make the retained-memory regression test exercise HighlyCompressedMapStatus

The test builds exactly 1900 + 100 = 2000 partitions. However, MapStatus.apply selects HighlyCompressedMapStatus only when uncompressedSizes.length > spark.shuffle.minNumPartitionsToHighlyCompress, whose default is 2000.

Consequently, both retainedBytes(-1.0) and retainedBytes(5.0) instantiate CompressedMapStatus. That implementation ignores the skew factor, so both retained sizes are identical, perBlock == 0, and this assertion succeeds even if HighlyCompressedMapStatus regresses to the previously reported boxed-map representation.

Please use at least 2,001 partitions, for example 1,901 small blocks plus 100 skewed blocks, and assert that each measured status is a HighlyCompressedMapStatus before estimating its retained size.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, and the test was worse than under-powered: it passed because the feature never ran. 1900 + 100 = 2000 and MapStatus.apply compares with >, so both measurements built a CompressedMapStatus, perBlock was exactly 0.0, and 0.0 < 16 held. The assertion was one-sided with no floor and no type check, so "the skew path stored nothing" and "the skew path stored something compact" were indistinguishable to it.

Fixed in 4196cb4: 1,901 small blocks plus 100 skewed, an assertion that the block count exceeds spark.shuffle.minNumPartitionsToHighlyCompress, an isInstanceOf[HighlyCompressedMapStatus] check on each measured status, and a check that the expected number of skewed sizes actually came back accurate before anything is measured.

if (size > skewCutoff) {
isAccurate = true
} else if (numTiedSkewedBlocksToRecord > 0) {
// Ties are broken by block index so that the map status stays deterministic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] The new rotation still leaves genuinely skewed reducers invisible

This remains reproducible at d3be2ba389d6594077219f52be0d5c5919e068a5. The new floorMod(mapTaskId, numTiedSkewedBlocks) advances a 100-entry tied-reducer window by only one position per consecutive mapper. Therefore M mappers cover only 100 + M - 1 tied reducers before wraparound.

Concrete default-configuration reproduction:

  • 2,001 reduce partitions and 10 map tasks.
  • Reducers 0-1880 produce 5 MiB blocks on every mapper.
  • Reducers 1881-2000 produce 50 MiB blocks on every mapper, below the 100 MiB mandatory-accuracy threshold.
  • The accurate-skew cap remains 100 per mapper.

Each status reports avgSize = 5,739,312 bytes for unselected blocks. Reducers 1990-2000 never enter any accurate window, so MapOutputTracker reports 57,393,120 bytes although each reducer actually contains 524,288,000 bytes. AQE computes a skew threshold of max(256 MiB, 5 * 57,393,120) = 286,965,600 bytes, so these genuinely skewed reducers remain unsplit. Overall, 19 of the 120 tied reducers miss the threshold.

There is also a second failure mode even when rotation distributes selections evenly: with 1,801 reducers producing 10 KiB blocks, 200 tied reducers producing 100 KiB blocks, and 10,000 mappers, each tied reducer is recorded on half the mappers. Unselected ties inflate avgSize to 15,087 bytes, so each skewed reducer is reported as 636,320,000 bytes against an AQE threshold of 754,350,000, even though its actual size is 1,024,000,000. All 200 genuinely skewed reducers are missed.

The new regression only covers 101 ties and 2,000 mappers, where almost every tied block is recorded on almost every mapper. Please cover both fewer-mapper/larger-tie groups and fairly sampled larger tie groups, while preserving the per-status cap and AQE visibility.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

Both reproductions confirmed at d3be2ba, and they land on your numbers exactly. A reports a median of 57,393,120 and a threshold of 286,965,600, with 19 of the 120 tied reducers under it. B reports 636,320,000 against 754,350,000, all 200 missed.

I don't think this can be fixed by rotating better, so rather than send another window schedule I want to make the case that the approach is wrong.

In B the tie group is exactly twice the cap, so coverage is already a perfect 50% and stays 50% under any rotation policy, including a full-window stride. The miss doesn't come from coverage. It comes from avgSize: the 100 ties each map task drops are folded into totalSmallBlockSize, lifting avgSize to 15,088, lifting the median reducer to 150,870,000, lifting the threshold to 754,350,000. Recording more ties per map task raises the reported size of the tied reducers and the threshold they're compared against at the same time. No schedule closes that gap. A is a genuine coverage bug on top of it — a stride of one reaches only 100 + M - 1 ties — but fixing the stride alone leaves B failing.

So I've stopped rationing the ties. They're all exactly skewCutoff, so the missing information is which reducers are at that size, not what their sizes are. That's one bitmap plus one byte: HighlyCompressedMapStatus now carries a run-optimized RoaringBitmap of the tied blocks alongside the existing emptyBlocks one, and the shared compressed cutoff size. Ties are excluded from the avgSize average, since they're recorded exactly and folding them in would only inflate the size reported for the blocks that really are average. The rotation, the tie counting pass and the mapTaskId-derived offset are gone; the id and size arrays still hold only the blocks strictly above the cutoff, which nthSmallest bounds.

This does drop the per-status cap on tied blocks, which your P1 asked for and which you asked me to preserve here. That's deliberate — the cap is the thing that makes this unsolvable — but it's your call, so the cost is measured below rather than asserted.

reproduction A reproduction B
tied reducers / map tasks 120 / 10 200 / 10,000
real size per tied reducer 524,288,000 1,024,000,000
reported, before 57,393,120 636,320,000
AQE threshold, before 286,965,600 754,350,000
detected, before 101 / 120 0 / 200
reported, after 550,087,940 1,121,770,000
AQE threshold, after 268,435,456 512,000,000
detected, after 120 / 120 200 / 200

Retained driver heap, SizeEstimator over the 10,000 deserialized map statuses of a 2,001 partition stage, which is what your earlier P2 asked for:

bytes per map task serialized
factor -1.0, before 164 13 KiB
factor 5.0, before (100 ties recorded) 668 27 KiB
factor -1.0, after 172 15 KiB
factor 5.0, after (every tie recorded) 308 15 KiB

Recording all 200 ties now costs less than half of what recording 100 of them did. Two caveats. The disabled case grows 8 bytes per map task for the extra field; statuses with no ties share one never-mutated empty bitmap, which is what keeps it to that. And the bitmap's cost tracks partition count, not tie count: your 50,000-partition case from the first round, with 24,999 ties, is a single run and retains 448 bytes per status, but a maximally scattered tie set at that width would be closer to 6.25 KiB, which is worse than the capped array. Bounded by the partition count either way, which the id array was not.

HighlyCompressedMapStatusBenchmark, back to back on the same machine, ns per block to build one status with sizes recorded:

partitions skewed, before skewed, after organ pipe, before organ pipe, after
2,048 17,875 18,250 24,875 24,917
10,000 56,750 55,833 105,208 100,333
50,000 346,417 310,750 555,458 531,458

Construction is flat to slightly faster — the tie counting pass is gone, which pays for the bitmap insertions — and the -1.0 cases are unchanged. getSizeForBlock gets faster, 4.7 to 3.9 ns per block at 2,048 partitions and 3.9 to 3.0 at 50,000, since the ties leave the binary-searched array. Laptop, single run per case, so treat small differences as noise and the direction as the claim.

On the tests, the two rounds of misses share a cause worth naming: they asserted the mechanism — coverage, and per-reducer error against a fixed target — rather than the property. Both sides of AQE's comparison come out of the map statuses, so a test that fixes the target can't see the threshold move. The replacement computes max(skewedPartitionThresholdInBytes, skewedPartitionFactor * medianReportedSize) from the statuses themselves and requires every skewed reducer to clear it, over three shapes: your A, your B, and the 101-tie/many-mapper case. It fails on d3be2ba with

106662602 was not greater than 286965600 reducer 1881 holds 524288000 bytes but is reported as 106662602, below the AQE skew threshold of 286965600, so it would never be split

One thing that fell out of writing it: the previous rotation test used 2,000 map tasks and 100 KiB tied blocks, so its tied reducers held 204,800,000 bytes — below the 256 MiB floor. They'd never be split even if reported perfectly, so that test was never measuring AQE visibility at all. The new case uses 5,000 map tasks.

@Vivek1106-04
Vivek1106-04 requested a review from sunchao August 4, 2026 07:58

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One verified P2 remains at 4196cb4: non-contiguous cutoff ties bypass the configured metadata bound and cause partition-proportional retained driver memory.

hugeBlockIds += i
hugeBlockSizes += MapStatus.compressSize(size)
} else if (recordSkewedTies && size == skewCutoff) {
skewedBlocks.add(i)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Bound cutoff-tie bitmap growth for non-contiguous reducer IDs

Recording every skewCutoff tie in skewedBlocks means spark.shuffle.maxAccurateSkewedBlockNumber=100 no longer bounds retained or serialized metadata. The new regression test makes all 24,999 tied reducer IDs consecutive, so runOptimize() compresses them into one run; that is not representative of arbitrary reducer IDs.

Using this exact head and its declared RoaringBitmap 1.6.10 dependency, with 50,000 reducers, 25,001 blocks of 1 KiB, and 24,999 blocks of 8 KiB:

  • Consecutive tied reducer IDs: bitmap retained payload 18 bytes; serialized bitmap 15 bytes.
  • Alternating tied reducer IDs with the identical block-size distribution: retained payload 8,202 bytes; serialized bitmap 8,208 bytes, after the same trim() and runOptimize() calls as production.

Thus 10,000 mappers retain at least 82,020,000 bitmap bytes, and 100,000 mappers retain at least 820,200,000 bytes, despite the configured 100-entry cap. At 1,048,576 alternating reducers, the measured bitmap is 131,112 bytes per map status, or 1,311,120,000 retained bytes for 10,000 mappers. These are bitmap-payload lower bounds; object overhead and cached serialized/broadcast copies are additional, and serialized sizes above are before compression.

The current retained < 8 * 1024 test would already fail for the 50,000-partition alternating layout. Please bound tie metadata independently of reducer count while preserving AQE visibility, and add an interleaved/fragmented-cutoff-tie retained-memory and serialization regression.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

The consecutive layout was the whole reason that bound looked comfortable: 448 bytes retained for the status, because 24,999 consecutive ids collapse into one run container. Interleaving the same size distribution retains 8,632 bytes, so the old retained < 8 * 1024 assertion would indeed have rejected it. Both layouts are covered now, and the bound is stated in terms of the id space instead of a constant: a RoaringBitmap over ids below 65,536 holds one container, and a container denser than 4,096 ids becomes a fixed 8 KiB bitmap, so no layout of these ids can cost more than that. The serialized size is asserted too (8,372 bytes interleaved).

On bounding the tie metadata independently of the reducer count, I don't think that is reachable together with what you asked for on Aug 3, and I'd rather say so than pretend otherwise.

What the bitmap costs is a bit per reduce partition. That is the same bound emptyBlocks in this class has always had, and I measured it on the same layouts you did:

layout tie bitmap emptyBlocks on the identical id layout
50,000 reducers, consecutive 18 retained / 15 serialized 18 / 15
50,000 reducers, alternating 8,202 / 8,208 8,202 / 8,208
1,048,576 reducers, alternating 131,112 / 131,208 131,112 / 131,208

At the whole-status level the interleaved tie status retains 8,632 bytes, against 8,656 bytes for a status holding those very ids as empty blocks. Recording the ties costs slightly less than the bitmap this class already carried, and the new test asserts that relationship directly, so it holds across RoaringBitmap upgrades rather than resting on today's container sizes.

The ties also cannot be more than half the blocks. recordSkewedTies requires skewThreshold <= skewCutoff, and skewThreshold is max(medianSize * accurateBlockSkewedFactor, skewCutoff) when it is below spark.shuffle.accurateBlockThreshold, so it requires medianSize * 5 <= skewCutoff. The median block is therefore strictly smaller than the cutoff, and fewer than half the blocks can sit at it. Your 24,999-of-50,000 construction is right at that ceiling. If the cutoff is at or above spark.shuffle.accurateBlockThreshold instead, the ties are recorded through the mandatory-accuracy path that predates this PR and never reach the bitmap. I added a comment recording both facts.

That leaves the tension: capping tie cardinality is what makes the cost reducer-count-independent, and it is also exactly what made genuinely skewed reducers invisible in your Aug 3 reproduction, since every mapper sees the same distribution and the unrecorded ties fall back to avgSize and inflate it. Distinguishing an arbitrary ~n/2 subset of n reducers needs Θ(n) bits however it is encoded, so I don't see a representation that keeps AQE visibility and drops the per-reducer term. What is still bounded by spark.shuffle.maxAccurateSkewedBlockNumber is the id-and-size array, which was the unbounded cost in the original finding: at most 100 entries of 5 bytes, regardless of how many ties exist.

For scale, on the two-population distribution from your own Aug 3 example (2,001 reducers, 200 tied), the bitmap is 414 bytes retained. The 8 KiB and 131 KiB figures need roughly half the reducers tied at the cutoff and scattered across every 64Ki block of the id space. An operator who does hit that and would rather not pay for it can set spark.shuffle.accurateBlockSkewedFactor to -1, which restores the previous behavior.

@Vivek1106-04
Vivek1106-04 requested a review from sunchao August 7, 2026 03:02
@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@sunchao! can you review the latest changes. Thank You!

@uros-b uros-b left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@Vivek1106-04 Please make sure to update the migration guide accordingly (docs/core-migration-guide.md).

This flips a shipped default in a way that changes query plans (skew joins now split by default above 2000 partitions) and adds driver memory per large stage, a user-visible behavior change. Every core default flip in core-migration-guide.md carries a "Since Spark X, ... To restore the legacy behavior, set to " entry. This PR adds no such entry; the only note lives in the config .doc.

@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

@uros-b added an entry to docs/core-migration-guide.md. Since branch-4.3 is already cut, it opens a new "Upgrading from Core 4.3 to 4.4" section:

Since Spark 4.4, Spark accurately records skewed shuffle block sizes in HighlyCompressedMapStatus, so that adaptive query execution can still detect skewed partitions in shuffles with more than 2000 partitions. The default of spark.shuffle.accurateBlockSkewedFactor changed from -1.0 to 5.0, matching spark.sql.adaptive.skewJoin.skewedPartitionFactor. This can change query plans, since such skewed partitions are now split by skew join optimization, and it retains up to spark.shuffle.maxAccurateSkewedBlockNumber (100 by default) block sizes per map task on the driver. To restore the legacy behavior, you can set spark.shuffle.accurateBlockSkewedFactor to -1.0.

@Vivek1106-04
Vivek1106-04 requested a review from uros-b August 16, 2026 10:04
@uros-b

uros-b commented Aug 23, 2026

Copy link
Copy Markdown
Member

One more minor note. Migration guide + config .doc understate worst-case driver memory. For this now-default-ON change, the docs say it retains "up to 100 block sizes per map task" but omit the new cutoff-tie RoaringBitmap, whose scattered-ties worst case is partition-proportional (~62 MiB for a 50000-partition × 10000-task stage).

}

test("SPARK-48290: nthSmallest partitions the array around the returned element") {
// HighlyCompressedMapStatus counts the block sizes above the cutoff by scanning only the tail

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: Factually wrong test comment at UtilsSuite.scala:1932-1933 claims HighlyCompressedMapStatus "scans only the tail of the array," but the shipped apply iterates the original uncompressedSizes(i) against the scalar skewCutoff and never scans the partitioned tail. Kept as a concern (not demoted) because it's a factual inaccuracy in shipped code, not just PR prose. The test itself is fine, only its stated reason is wrong.

@uros-b

uros-b commented Aug 23, 2026

Copy link
Copy Markdown
Member

I left a few more comments, @Vivek1106-04 PTAL. but otherwise lgtm!

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed 4b654a06259f1d2dd4e9dc38222051d0daca39a1 against eb3b13823b1b70e131b1fb70d742cddd7b2de21d with five independent review passes. No new actionable P1/P2 findings.

The introselect fallback, cutoff-tie visibility, and compact storage changes check out. The tie bitmap still has partition-proportional memory cost; I accept that discussed design tradeoff rather than treating it as a new blocker. The existing requests to clarify the memory documentation and correct the stale test comment still apply.

Validation: all nine added regression tests passed in an isolated harness using exact-head source and cached Spark dependencies, alongside adversarial selection, map-size equivalence, serialization, and AQE-splitting probes. This was not a full Spark rebuild or an end-to-end AQE integration run. The reviewed head/base are unchanged and the current PR checks are green.

LGTM.

@Vivek1106-04
Vivek1106-04 requested a review from uros-b August 24, 2026 06:39

@uros-b uros-b left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you @Vivek1106-04 and @sunchao!

…hat AQE can detect skew

Once a shuffle has more than spark.shuffle.minNumPartitionsToHighlyCompress
(2000) partitions, map statuses switch to HighlyCompressedMapStatus, which
reports every block below spark.shuffle.accurateBlockThreshold as the average
block size. A skewed partition whose rows are spread over many map tasks has no
individual block above that threshold, so its size is averaged away, adaptive
query execution sees min == median == max and the skew is left unhandled.

spark.shuffle.accurateBlockSkewedFactor, added by SPARK-36967, keeps the sizes
of skewed blocks accurate, but it shipped disabled because enabling it sorted
the block sizes in every map task. Only two order statistics are needed, so
they are now selected in O(numPartitions) instead of sorting, and the feature
is enabled by default with the value its documentation already recommended.
…lection cost

Address two problems with enabling the accurate recording of skewed block
sizes by default.

The number of accurately recorded blocks was only bounded by the size of the
cutoff block, so blocks tied at that size were all recorded and could exceed
spark.shuffle.maxAccurateSkewedBlockNumber by orders of magnitude. With 50000
reduce partitions, 25001 blocks of 1 KiB and 24999 blocks of 8 KiB, all 24999
larger blocks were recorded instead of at most 100. The blocks strictly larger
than the cutoff are now counted up front, and the rest of the budget admits the
tied blocks in block index order, which keeps the map status deterministic.
Blocks at or above spark.shuffle.accurateBlockThreshold are still always
recorded, as they were before.

Utils.nthSmallest picked the middle element as the pivot, which is quadratic
for an organ pipe distribution of block sizes. It is now an introselect: the
pivot is the median of the first, middle and last element, and the range still
under consideration is sorted once the ranges have stopped shrinking quickly
enough, which bounds the worst case at O(numPartitions * log(numPartitions)).

Both cases are covered by new tests, the ordering that the block count relies
on is now asserted directly, and the benchmark also reports the organ pipe
distribution.
…re the accurate sizes compactly

Every map task of a shuffle sees the same block size distribution, so breaking
the ties at the skew cutoff by block index recorded the same reduce ids in every
map status, and a reducer that lost the tie was represented by avgSize in all of
them. MapOutputTracker sums those, so a genuinely skewed reducer could stay
invisible to adaptive query execution. The window of tied blocks a map task
records now rotates with its map task id, so a tied reducer loses on one map
task out of the number of tied blocks rather than on all of them. The map status
stays deterministic, avgSize is unchanged, and the per map task cap still holds.
Counting the tied blocks costs one more pass over the sizes, so it is only paid
when a tied block can be recorded at all.

HighlyCompressedMapStatus held the accurate sizes in a mutable.Map[Int, Byte],
one of which the driver retains per map task for the lifetime of the shuffle.
They are now two parallel primitive arrays sorted by reduce id, which
getSizeForBlock binary searches. Measured on a MapOutputTrackerMaster holding a
10000 map task stage of 2001 reducers with 101 blocks tied at the cutoff, the
retained map statuses go from 6004 to 668 bytes per map task, or from 57 MiB to
6 MiB for the stage. Reading a block size also gets cheaper, from 6.5 to 3.9
nanoseconds at 50000 partitions, since the reduce id is no longer boxed on
every lookup. The benchmark reports both, alongside the build cost.
…off in a bitmap

Blocks tied at the skew cutoff were rationed to fit
spark.shuffle.maxAccurateSkewedBlockNumber, and a rotation derived from the map
task id spread which of them each map task recorded. A tie the rotation leaves
out falls back to avgSize, which it raises in the process, so AQE's skew
threshold rises along with the sizes it is compared against. With twice as many
ties as the cap the coverage is already an even 50% under any schedule, and
every tied reducer still reports below the threshold, so no window schedule
fixes it.

The tied blocks all share one size, so record their membership rather than their
sizes: a run-optimized RoaringBitmap alongside the existing emptyBlocks one,
plus the shared compressed cutoff size. Ties are excluded from the avgSize
average, since they are recorded exactly. The rotation, the tie counting pass
and the map task id derived offset are gone, and the id and size arrays still
hold only the blocks strictly above the cutoff, which nthSmallest bounds.
Statuses with no ties share one never-mutated empty bitmap.

The rotation tests asserted coverage and per-reducer error against a fixed
target, which cannot see the threshold move, so they are replaced by one that
computes the AQE skew threshold from the map statuses themselves and requires
every skewed reducer to clear it, over three tie group and map task count
ratios. The retained-memory test built exactly
spark.shuffle.minNumPartitionsToHighlyCompress blocks, so it measured a
CompressedMapStatus and passed with the skew path never running; it now uses one
more block and asserts the status type and that the sizes were recorded.
…ained memory regression

The regression for recording every block tied at the skew cutoff placed
all of the tied reduce ids consecutively, so the bitmap holding them
compressed into a single run and retained 448 bytes. Interleaving the
same size distribution produces the densest layout a bitmap can hold and
retains 8,632 bytes, which the previous 8 KiB bound would have rejected.

Cover both layouts, and bound them by the size of the id space rather
than by a constant: a RoaringBitmap over ids below 65,536 holds one
container, and a container denser than 4,096 ids becomes a fixed 8 KiB
bitmap, so no layout of these ids can cost more. Assert the serialized
size as well, and assert directly that recording the ties costs no more
than a status holding those very ids as empty blocks, which is the bound
this class has always had.
…ult in the core migration guide

Flipping spark.shuffle.accurateBlockSkewedFactor from -1.0 to 5.0 changes
query plans, since skewed partitions in shuffles above 2000 partitions are
now visible to adaptive query execution and split by the skew join
optimization, and it retains block sizes per map task on the driver. Both
are user-visible, and the only note lived in the config doc.

Open the "Upgrading from Core 4.3 to 4.4" section, matching the section the
SQL migration guide already carries, and record the default flip along with
the config that restores the legacy behavior.
…ost and fix a stale test comment

The migration guide entry and the `spark.shuffle.accurateBlockSkewedFactor`
documentation both described the driver memory cost as at most
`spark.shuffle.maxAccurateSkewedBlockNumber` block sizes per map task, which
omits the bitmap of the blocks tied at the cutoff. That bitmap is not capped:
in the worst case, where the ties are scattered across the reduce id space, it
costs a bit per reduce partition per map task, measured at 8202 bytes per map
status for a 50000 partition stage. Both places now state that bound.

The comment on the `nthSmallest` partitioning test claimed that
`HighlyCompressedMapStatus` counts the sizes above the cutoff by scanning the
tail of the partitioned array. That was true of an earlier revision of this
change and stopped being true once the ties moved into the bitmap: `apply`
compares each `uncompressedSizes(i)` against the scalar cutoff instead. The
test still pins the partial ordering that `nthSmallest` documents, so only the
stated reason changes.
@Vivek1106-04
Vivek1106-04 force-pushed the SPARK-48290-skew-mapstatus branch from 3dc14ea to af9cd81 Compare October 14, 2026 04:22
@sunchao sunchao closed this in a125337 Oct 19, 2026
sunchao pushed a commit that referenced this pull request Oct 19, 2026
…hat AQE can detect skew

### What changes were proposed in this pull request?

Once a shuffle has more than `spark.shuffle.minNumPartitionsToHighlyCompress` (2000) partitions, map statuses switch to `HighlyCompressedMapStatus`, which keeps exact sizes only for blocks above `spark.shuffle.accurateBlockThreshold` (100MB) and reports the average block size for everything else. `MapOutputTracker.getStatistics` sums those per reduce id, so AQE ends up looking at a flat distribution.

`spark.shuffle.accurateBlockSkewedFactor`, added by SPARK-36967, keeps skewed block sizes accurate, but it ships disabled (`-1.0`) even though its own documentation recommends setting it to the same value as `spark.sql.adaptive.skewJoin.skewedPartitionFactor`. Enabling it fully sorted the block sizes in every map task, which is presumably why it was left off.

This PR:
1. replaces the sort in `HighlyCompressedMapStatus.apply` with selection of the only two order statistics it needs (the median, and the K-th largest size), using a new in-place `Utils.nthSmallest` plus `Utils.medianInPlace`. This is O(numPartitions) on average rather than O(numPartitions log numPartitions), with the same result. `nthSmallest` is an introselect: the pivot is the median of the first, middle and last element, and it sorts the range still under consideration once the ranges have stopped shrinking quickly enough, which bounds the worst case at O(numPartitions log numPartitions) even for adversarial inputs such as an organ pipe distribution;
2. enforces `spark.shuffle.maxAccurateSkewedBlockNumber` as an actual cardinality bound. Previously only the *size* of the cutoff block bounded the recording, so blocks tied at that size were all recorded. The blocks strictly larger than the cutoff are now counted up front, and the remainder of the budget admits tied blocks in block index order, which keeps the map status deterministic. Blocks at or above `spark.shuffle.accurateBlockThreshold` are still always recorded, as they were before;
3. changes the default of `spark.shuffle.accurateBlockSkewedFactor` from `-1.0` to `5.0`, matching `spark.sql.adaptive.skewJoin.skewedPartitionFactor`. `-1.0` still disables the feature;
4. updates the config documentation to say what leaving it off actually costs.

### Why are the changes needed?

Skew join optimization silently stops working above 2000 shuffle partitions. In the reporter's case a 263GB partition spread over 10335 mappers is only ~25MB per block, far below the 100MB accurate threshold, so every block is reported as the average and AQE logs `median size == max size == min size` and finds no skew. Lowering `spark.shuffle.accurateBlockThreshold` does not help, because what matters is the per-block size rather than the per-partition total.

Reproduced on master with a join where 95% of the rows land on one key, varying only `spark.sql.shuffle.partitions`:

| `spark.sql.shuffle.partitions` | extra configuration | skew detected |
| --- | --- | --- |
| 1999 | (defaults) | yes, `SortMergeJoin(skew=true)` |
| 2001 | (defaults) | no |
| 4000 | (defaults) | no |
| 2001 | `spark.shuffle.accurateBlockSkewedFactor=5.0` | yes |
| 2001 | `spark.shuffle.minNumPartitionsToHighlyCompress=100000` | yes |

Either knob restores detection on its own, which locates the problem in the map status compression rather than in AQE.

The cost of the change, measured with the benchmark added here (per map task, Apple M4, JDK 21):

| shuffle partitions | skewed sizes not recorded | recorded, skewed distribution | recorded, organ pipe distribution |
| --- | --- | --- | --- |
| 2048 | 6.4us | 17.3us | 23.6us |
| 10000 | 14.7us | 56.3us | 99.5us |
| 50000 | 54.6us | 309.8us | 530.2us |

For comparison, the sorting implementation this PR replaces measured 28.8us, 140.7us and 1562.0us on the same machine, so at 50000 partitions the accurate path is now about 5x cheaper than sorting would have been. The organ pipe column is the worst case for the selection, where the introselect fallback kicks in; it stays within 1.7x of the ordinary case and does not grow quadratically.

On the driver side each map status carries at most `spark.shuffle.maxAccurateSkewedBlockNumber` (100) extra entries of an int and a byte, so about 500 bytes per map task, or ~5MB for a 10000 map task stage.

### Does this PR introduce _any_ user-facing change?

Yes. Shuffles with more than `spark.shuffle.minNumPartitionsToHighlyCompress` partitions now report skewed block sizes accurately by default, so AQE can detect and split skewed partitions where it previously could not. Queries affected by this will change plans (skew joins get split) and should run faster. Setting `spark.shuffle.accurateBlockSkewedFactor=-1.0` restores the old behavior.

### How was this patch tested?

New tests in `MapStatusSuite`:
- a skewed block below `spark.shuffle.accurateBlockThreshold` is recorded accurately under default configuration, and the average is not inflated by it. Fails on master with `88733 did not equal 1500`.
- at 50000 blocks, 25001 of 1 KiB and 24999 of 8 KiB, exactly `spark.shuffle.maxAccurateSkewedBlockNumber` blocks are recorded and the rest report the average. Fails without the cardinality bound with `1024 did not equal 4600`, because all 24999 tied blocks were recorded and the average collapsed to the size of the small blocks.

New tests in `UtilsSuite`:
- `Utils.nthSmallest` agrees with a full sort over sorted, reverse sorted, constant, organ pipe, duplicate-heavy and random inputs, and its argument validation.
- `Utils.nthSmallest` leaves the array partitioned around the returned element. `HighlyCompressedMapStatus` counts the sizes above the cutoff by scanning only the tail of the array, so it depends on this and not just on the returned value.
- six selections over a 1048576 element organ pipe complete within a 60 second limit. A quadratic selection needs more than 10^11 comparisons there; the introselect takes 100ms in total.
- `Utils.medianInPlace` agrees with `Utils.median`.

`HighlyCompressedMapStatusBenchmark` is new, with results generated at 2048, 10000 and 50000 partitions for both distributions.

Existing suites: `MapStatusSuite`, `MapOutputTrackerSuite`, `ShuffleSuite`, `UtilsSuite`, `org.apache.spark.shuffle.sort.*` and `AdaptiveQueryExecSuite` (129 tests) all pass.

End to end, with a locally built assembly and no configuration beyond the SQL level skew join settings, the table above becomes yes / yes / yes at 1999, 2001 and 4000 partitions.

I did not add an AQE level test for the >2000 partition case: `spark.shuffle.minNumPartitionsToHighlyCompress` is read through a lazy val on the JVM's `SparkEnv`, so it cannot be lowered from within a suite, and running a query with 2001 shuffle partitions takes about a minute. SPARK-36967 tested this feature at `MapStatusSuite` level for the same reason. Happy to add one if reviewers would rather pay the runtime.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code

Closes #57528 from Vivek1106-04/SPARK-48290-skew-mapstatus.

Authored-by: Gangavarapu Vivek <[email protected]>
Signed-off-by: Chao Sun <[email protected]>
(cherry picked from commit a125337)
Signed-off-by: Chao Sun <[email protected]>
@sunchao

sunchao commented Oct 19, 2026

Copy link
Copy Markdown
Member

Merge Summary:

Posted by merge_spark_pr.py

Build HighlyCompressedMapStatus, skewed block sizes
================================================================================================

OpenJDK 64-Bit Server VM 21.0.10 on Mac OS X 26.5.2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hi, @Vivek1106-04 , @sunchao , @uros-b .

You should not generate the benchmark result locally.

Since this PR was generated in a wrong way, we didn't catch the Apache Spark benchmark outages.

Please see the failure.

@dongjoon-hyun

Copy link
Copy Markdown
Member

I made a PR to recover the Apache Spark benchmark CI.

dongjoon-hyun added a commit that referenced this pull request Nov 6, 2026
…ilure when `mockito` is not on the classpath

### What changes were proposed in this pull request?

This PR removes the Mockito dependency from `HighlyCompressedMapStatusBenchmark`.

- Instead of mocking `SparkEnv` for each case, the benchmark now starts a single `local[1]` `SparkContext` and changes `spark.shuffle.accurateBlockSkewedFactor` on `SparkEnv.get.conf` before each case. `HighlyCompressedMapStatus` reads this value from `SparkEnv.get.conf` on every call, so the behavior is the same as the mock.
- The `SparkContext` is stopped before the "Driver heap retained by MapOutputTracker" report, which creates its own `SparkContext`.

### Why are the changes needed?

The benchmark job fails with:

```
java.lang.NoClassDefFoundError: org/mockito/Mockito
  at org.apache.spark.HighlyCompressedMapStatusBenchmark$.envWithSkewedFactor(HighlyCompressedMapStatusBenchmark.scala:52)
```

(https://github.com/dongjoon-hyun/spark/actions/runs/41684094207/job/127686502284)

The GitHub Actions benchmark workflow runs `Benchmarks` through `spark-submit --jars <test jars>`. `mockito-core` is a test-scope dependency of `core`, so it is not on that classpath. `build/sbt "core/Test/runMain ..."` does include test dependencies, which is why the failure does not reproduce locally.

The benchmark was added by SPARK-48290 (a125337, 2026-10-19). Other benchmarks do not depend on Mockito.
- #57528

### Does this PR introduce _any_ user-facing change?

No. This only changes a benchmark.

### How was this patch tested?

Ran the benchmark locally and confirmed that it completes successfully and that the reported numbers (for example, the bytes retained per map task) are the same as before the change:

```
build/sbt "core/Test/runMain org.apache.spark.HighlyCompressedMapStatusBenchmark"
```

The benchmark has not been run through `spark-submit --jars` yet, so the GitHub Actions benchmark job needs to confirm the fix.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Sonnet 5.5

Closes #60160 from dongjoon-hyun/SPARK-60984.

Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
dongjoon-hyun added a commit that referenced this pull request Nov 6, 2026
…ilure when `mockito` is not on the classpath

### What changes were proposed in this pull request?

This PR removes the Mockito dependency from `HighlyCompressedMapStatusBenchmark`.

- Instead of mocking `SparkEnv` for each case, the benchmark now starts a single `local[1]` `SparkContext` and changes `spark.shuffle.accurateBlockSkewedFactor` on `SparkEnv.get.conf` before each case. `HighlyCompressedMapStatus` reads this value from `SparkEnv.get.conf` on every call, so the behavior is the same as the mock.
- The `SparkContext` is stopped before the "Driver heap retained by MapOutputTracker" report, which creates its own `SparkContext`.

### Why are the changes needed?

The benchmark job fails with:

```
java.lang.NoClassDefFoundError: org/mockito/Mockito
  at org.apache.spark.HighlyCompressedMapStatusBenchmark$.envWithSkewedFactor(HighlyCompressedMapStatusBenchmark.scala:52)
```

(https://github.com/dongjoon-hyun/spark/actions/runs/41684094207/job/127686502284)

The GitHub Actions benchmark workflow runs `Benchmarks` through `spark-submit --jars <test jars>`. `mockito-core` is a test-scope dependency of `core`, so it is not on that classpath. `build/sbt "core/Test/runMain ..."` does include test dependencies, which is why the failure does not reproduce locally.

The benchmark was added by SPARK-48290 (a125337, 2026-10-19). Other benchmarks do not depend on Mockito.
- #57528

### Does this PR introduce _any_ user-facing change?

No. This only changes a benchmark.

### How was this patch tested?

Ran the benchmark locally and confirmed that it completes successfully and that the reported numbers (for example, the bytes retained per map task) are the same as before the change:

```
build/sbt "core/Test/runMain org.apache.spark.HighlyCompressedMapStatusBenchmark"
```

The benchmark has not been run through `spark-submit --jars` yet, so the GitHub Actions benchmark job needs to confirm the fix.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Sonnet 5.5

Closes #60160 from dongjoon-hyun/SPARK-60984.

Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
(cherry picked from commit 7af0d94)
Signed-off-by: Dongjoon Hyun <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants