Broadcast vs Partitioned Spatial Joins in Sedona

Broadcast when the small side’s decoded size fits comfortably in executor memory; partition when it does not. The word doing the work is “decoded”: compressed GeoParquet expands three to six times when its geometry becomes JVM objects with an R-tree over them, so the file size that looks safe is routinely the one that produces an out-of-memory failure forty minutes into a job. This page is the sizing decision behind Apache Sedona for distributed spatial joins.

Quick Reference

Small side, compressed Decoded estimate 16 GB executors 64 GB executors Primary Use Case
200 MB ~1 GB Broadcast Broadcast Administrative boundaries
800 MB ~4 GB Marginal — measure Broadcast Building footprints, one country
2 GB ~10 GB Partition Broadcast National parcel coverage
6 GB ~30 GB Partition Partition Continental footprint sets
Unknown Measure first Measure first Always

Why Decoded Size Is the Number

A GeoParquet file at ZSTD level 3 stores geometry as compressed WKB. Broadcasting it means every executor holds, simultaneously: the decompressed WKB bytes, the parsed geometry objects, the R-tree nodes indexing them, and the attribute columns. Each of those is real memory and only the first bears any resemblance to the file size.

The expansion factor varies with geometry complexity. Simple points expand least — maybe two and a half times. Detailed polygons with hundreds of vertices expand most, because per-object overhead is paid per geometry and the R-tree adds a node per entry. Three to six times covers the range for typical vector layers, and the safe move is to measure rather than assume.

The consequence is that the interesting comparison is not “is this table small” but “does this table, decoded, fit in a fraction of one executor’s heap, with room left for the streaming side.”

What a 2 GB broadcast table actually occupies per executor A two gigabyte compressed GeoParquet file expands inside an executor into four components: decompressed well-known binary bytes, parsed geometry objects with per-object overhead, R-tree index nodes, and attribute columns. Together they occupy about ten gigabytes. Against a sixteen gigabyte executor heap that leaves insufficient room for the streaming side and shuffle buffers, so the job fails; against a sixty-four gigabyte heap it fits comfortably with room to spare. 2 GB compressed GeoParquet, broadcast to one executor 2 GB file WKB bytes 3.4 GB geometry objects 4.1 GB R-tree 1.6 GB attrs 0.9 GB ≈ 10 GB 16 GB executor heap broadcast 10 GB 6 GB for everything else → streaming side plus shuffle buffers do not fit; the job OOMs mid-stage 64 GB executor heap 10 GB 54 GB for the streaming side and buffers → comfortable; broadcast is the right call and eliminates the shuffle entirely The file size is identical in both rows. Only the heap changed, and with it the correct strategy.

Deciding and Enforcing

python
# Requires: apache-sedona[spark]>=1.6, pyspark>=3.5  (Python 3.10+)
from __future__ import annotations

from dataclasses import dataclass

from pyspark.sql import DataFrame, SparkSession
from pyspark.sql import functions as F

# Measured expansion from compressed GeoParquet to decoded JVM objects plus
# an R-tree. Points sit near the bottom, dense polygons near the top.
EXPANSION = {"point": 2.5, "line": 4.0, "polygon": 6.0}
SAFE_HEAP_FRACTION = 0.33


@dataclass(frozen=True)
class JoinDecision:
    strategy: str
    decoded_gb: float
    budget_gb: float
    reason: str


def decide(
    compressed_gb: float,
    dominant_type: str,
    executor_heap_gb: float,
) -> JoinDecision:
    """Choose broadcast or partitioned from decoded size, not file size."""
    factor = EXPANSION.get(dominant_type.lower())
    if factor is None:
        raise ValueError(f"unknown geometry class {dominant_type!r}")
    if compressed_gb <= 0 or executor_heap_gb <= 0:
        raise ValueError("sizes must be positive")

    decoded = compressed_gb * factor
    budget = executor_heap_gb * SAFE_HEAP_FRACTION
    if decoded <= budget:
        return JoinDecision(
            "broadcast", decoded, budget,
            f"{decoded:.1f} GB decoded fits the {budget:.1f} GB broadcast budget",
        )
    return JoinDecision(
        "partitioned", decoded, budget,
        f"{decoded:.1f} GB decoded exceeds the {budget:.1f} GB budget",
    )


def join(
    spark: SparkSession,
    large: DataFrame,
    small: DataFrame,
    decision: JoinDecision,
    *,
    partitions: int = 512,
) -> DataFrame:
    """Apply the chosen strategy explicitly rather than hoping the planner agrees."""
    if decision.strategy == "broadcast":
        # Raising the threshold is not enough on its own: the planner's own size
        # estimate for a geometry column is unreliable, so hint the join too.
        spark.conf.set(
            "spark.sql.autoBroadcastJoinThreshold",
            str(int(decision.budget_gb * 1024**3)),
        )
        right = F.broadcast(small.alias("s"))
    else:
        spark.conf.set("sedona.join.gridtype", "kdbtree")
        spark.conf.set("sedona.join.numpartition", str(partitions))
        spark.conf.set("spark.sql.shuffle.partitions", str(partitions))
        right = small.alias("s")

    return large.alias("l").join(
        right, F.expr("ST_Intersects(l.geometry, s.geometry)"), how="inner"
    )


def assert_strategy(frame: DataFrame, expected: str) -> None:
    """Fail loudly if the planner ignored the decision."""
    plan = frame._jdf.queryExecution().executedPlan().toString()
    got = (
        "broadcast" if "BroadcastIndexJoin" in plan
        else "partitioned" if ("RangeJoin" in plan or "DistanceJoin" in plan)
        else "nested-loop"
    )
    if got != expected:
        raise RuntimeError(
            f"expected a {expected} join, planner chose {got}:\n{plan[:600]}"
        )

Validation

Two numbers confirm the decision was right: peak executor memory, and shuffle bytes written. A broadcast join should show zero shuffle for the join stage; a partitioned join should show a peak well under the heap.

bash
# From the Spark event log: shuffle written by the join stage, peak executor memory
spark-submit --conf spark.eventLog.enabled=true ... join_job.py
python3 - <<'PY'
import json
peak = shuffle = 0
for line in open("/tmp/spark-events/application_1"):
    event = json.loads(line)
    if event.get("Event") == "SparkListenerTaskEnd":
        metrics = event.get("Task Metrics", {})
        shuffle += metrics.get("Shuffle Write Metrics", {}).get("Shuffle Bytes Written", 0)
        peak = max(peak, metrics.get("Peak Execution Memory", 0))
print(f"shuffle written {shuffle/1e9:.2f} GB · peak execution memory {peak/1e9:.2f} GB")
PY

Expected results: a broadcast join writes essentially zero shuffle bytes for the join stage and peaks at roughly the decoded broadcast size plus the streaming batch; a partitioned join writes shuffle bytes on the order of both inputs combined and peaks much lower. A broadcast job peaking near the heap ceiling is one dataset growth away from failing.

The same join measured under both strategies Three paired measurements for one join of eight hundred and twelve million points against eleven point six million polygons. Broadcast completes in nine minutes forty seconds with zero shuffle written and a peak of fourteen point one gigabytes per executor. Partitioned completes in fourteen minutes twenty seconds with one point nine terabytes of shuffle written and a peak of eight point seven gigabytes. The broadcast run is faster but sits close to the heap ceiling, which is the risk the partitioned run trades away. 812 M points ⋈ 11.6 M polygons, 12 × 16 GB executors Wall clock broadcast 9m 40s partitioned 14m 20s Shuffle written broadcast — 0 GB partitioned — 1.9 TB across both inputs Peak per executor broadcast 14.1 GB of 16 GB partitioned 8.7 GB of 16 GB one growth away from OOM When rebuilding the broadcast index dominates The cost of a broadcast join split into index build and join execution. For one long query the build is a small fraction of the total and the strategy is clearly right. For two hundred short queries the same build is repeated on every executor for every query, and it becomes the majority of the total work — at which point a partitioned join whose setup is cheaper finishes the batch sooner. The same broadcast, amortised two ways One long query build join execution build is 15% of the run — broadcast is clearly right 200 short queries the same build repeats on every executor for every query — 56% of the total work Cache the broadcast across queries if the engine allows it, or take the partitioned join.

Edge Cases and Caveats

Broadcast repeated across many short queries. Each query rebuilds the index on every executor, so a workload of hundreds of small joins pays that cost hundreds of times. Cache the broadcast side across queries where the engine allows it, or accept a partitioned join whose per-query setup is cheaper.

A small side that grows. Boundaries get subdivided, footprints get re-surveyed, and a table that fitted last quarter does not this quarter. Bake the decision into the job as a computed threshold rather than a hard-coded flag, so growth changes the strategy automatically instead of producing an OOM.

A planner that ignores the hint. Adaptive query execution can override a broadcast hint when its runtime statistics disagree. The assert_strategy check catches this, and it is worth running in production rather than only in development — the nested-loop fallback is the failure it exists to prevent.

Frequently Asked Questions

How large can the broadcast side of a spatial join be?

Judge it on the decoded size, not the file size. Compressed GeoParquet expands roughly three to six times when geometry becomes JVM objects and an R-tree is built over it, so a 2 GB file can occupy 10 GB per executor. A safe rule is to broadcast only when the decoded estimate fits within about a third of the executor heap, leaving room for the streaming side and the shuffle buffers.

Why does the planner choose the wrong join strategy so often?

Because its size estimates come from file statistics, and a geometry column’s compressed byte size is a poor predictor of its decoded footprint. The planner may see a 1.8 GB table and treat it as broadcastable when decoding will produce 9 GB, or refuse to broadcast a table that would have fitted comfortably. Setting the threshold explicitly, and hinting the join, replaces a guess with a measurement.

Is a broadcast join always faster when it fits?

Nearly always, because it eliminates the shuffle entirely — no data crosses the network between stages, and each executor queries a local R-tree. The exception is when the small side is close to the memory limit, where garbage collection pressure can make it slower than a clean partitioned join, and when the broadcast itself is repeated across many short queries, in which case building the index once per executor per query becomes the dominant cost.


← Back to Apache Sedona for Distributed Spatial Joins