HyukjinKwon commented on PR #60120:
URL: https://github.com/apache/spark/pull/60120#issuecomment-6651121437
### Benchmark: bundled cloudpickle 3.1.2 (master) vs cloudpickle-rust 0.1.0
(this PR)
**Setup:** Apple M3 Max (16 cores), macOS, CPython 3.14.5, JDK 26.0.1,
classic `local[4]`. Both variants run on the same build of this PR and differ
only in `python/pyspark/cloudpickle` (on the driver and in `pyspark.zip`): the
master variant restores the bundled cloudpickle from the parent commit.
Functions and classes are defined in `__main__`, so they are pickled by value
as in a notebook. The PR numbers include this PR's `Pickler` subclass that
keeps `co_filename`; plain `cloudpickle_rust.dumps` is another ~2-8% faster.
**Serialization only**, through the same calls as PySpark:
`CloudPickleSerializer().dumps(...)` for UDF commands and RDD batches, and
`pickle.loads` as the worker does. Best of 9 repeats in each of 3 processes per
variant.
| Case | `dumps` master | `dumps` PR | Speedup | `loads` master | `loads` PR
| Speedup |
|---|---:|---:|---:|---:|---:|---:|
| lambda | 11.5 µs | 8.8 µs | 1.31x | 4.6 µs | 4.4 µs | 1.03x |
| closure using globals and a helper | 21.8 µs | 15.7 µs | 1.39x | 8.9 µs |
8.7 µs | 1.03x |
| 1,000 distinct lambdas | 5.0 ms | 3.4 ms | 1.49x | 2.0 ms | 1.8 ms | 1.13x
|
| UDF command, lambda | 14.6 µs | 11.1 µs | 1.32x | 5.5 µs | 5.5 µs | 1.00x |
| UDF command, closure over a dynamic class | 56.5 µs | 42.3 µs | 1.34x |
33.8 µs | 32.4 µs | 1.04x |
| RDD batch, 1,024 ints | 10.0 µs | 10.0 µs | 1.00x | 15.0 µs | 15.2 µs |
0.98x |
| RDD batch, 1,024 (k, v) tuples | 46.3 µs | 46.6 µs | 0.99x | 47.1 µs |
46.6 µs | 1.01x |
| RDD batch, 1,024 dicts | 102.3 µs | 101.7 µs | 1.01x | 121.1 µs | 120.1 µs
| 1.01x |
| RDD batch, 1,024 dynamic class instances | 1.1 ms | 719.4 µs | 1.48x |
330.5 µs | 336.9 µs | 0.98x |
**End to end**, median of 5 processes per variant (each the median of 7 runs
after 2 warm-ups), with the range of the 5 process medians in parentheses:
| Case | master | PR | Speedup |
|---|---:|---:|---:|
| Python UDF, Arrow-optimized (default), 2M rows | 243 ms (225-474) | 260 ms
(225-314) | 0.93x |
| Python UDF, pickled rows (useArrow=False), struct result, 500k rows | 168
ms (153-236) | 159 ms (151-266) | 1.05x |
| RDD map + reduceByKey of (k, v) tuples, 1M records | 146 ms (140-153) |
145 ms (135-150) | 1.00x |
| RDD collect of dynamic class instances, 200k records | 183 ms (181-186) |
161 ms (160-171) | 1.14x |
| RDD parallelize + collect of tuples, 500k records | 127 ms (125-143) | 129
ms (124-131) | 0.99x |
| RDD of 400 tasks, closure over a dynamic class | 729 ms (663-1038) | 696
ms (685-828) | 1.05x |
| Broadcast of a 1M-entry dict, read by 8 tasks | 272 ms (262-292) | 263 ms
(258-287) | 1.04x |
**Takeaways**
- Pickling functions, closures and UDF commands is 1.3x-1.5x faster, and so
is pickling instances of classes defined in `__main__`. Unpickling is about the
same (1.0x-1.1x): it runs in CPython's C unpickler, and only the reconstructors
moved to Rust.
- Batches of builtin values (ints, tuples, dicts) are unchanged: CPython's
pickler handles `dict`, `list` and `tuple` before it calls `reducer_override`,
so no cloudpickle code runs for them.
- End to end, only the job that ships many dynamic class instances to the
driver is clearly faster (1.14x, the ranges do not overlap). In the other jobs
serialization is a small part of the time, and the differences are within
run-to-run noise (the ranges overlap), including both Python UDF cases.
- Broadcast values are pickled with the standard `pickle`
(`Broadcast.dump`), not cloudpickle, so the broadcast case is a control; it is
within noise too.
<details>
<summary>Benchmark script (<code>python bench_cloudpickle.py micro|e2e
OUTPUT_JSON</code>, run alternately against the two variants)</summary>
```python
#
# Benchmarks PySpark's cloudpickle usage, to compare the bundled cloudpickle
with cloudpickle-rust.
#
# Usage: python bench_cloudpickle.py micro|e2e OUTPUT_JSON
#
# micro: serialization only, through the same calls as PySpark
(CloudPickleSerializer for UDF
# commands and RDD batches, pickle.loads on the worker side). No
JVM.
# e2e: end-to-end jobs on a classic local[4] SparkSession.
#
# Everything is defined in __main__, so functions and classes are pickled by
value, as in a shell
# or a notebook.
import gc
import json
import operator
import os
import pickle
import sys
import time
import timeit
GLOBAL_OFFSET = 7
def helper(x):
return x * 3 + GLOBAL_OFFSET
def make_closure(n):
def inner(x):
return helper(x) * n + GLOBAL_OFFSET
return inner
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def norm1(self):
return abs(self.x) + abs(self.y)
class Model:
"""A small dynamic class captured by a UDF, e.g. a tokenizer or a
model."""
def __init__(self, n):
self.weights = {"w%d" % i: i * 0.5 for i in range(n)}
self.bias = 1.5
def predict(self, x):
return x * self.weights["w1"] + self.bias
def micro():
from pyspark import cloudpickle
from pyspark.serializers import CloudPickleSerializer
from pyspark.sql.types import DoubleType, StringType
ser = CloudPickleSerializer()
model = Model(100)
def udf_with_model(x):
return model.predict(helper(x))
cases = {
"lambda": lambda x: x + 1,
"closure using globals and a helper": make_closure(3),
"1,000 distinct lambdas": [(lambda i: lambda x: x + i)(i) for i in
range(1000)],
"UDF command, lambda": (lambda s: None if s is None else str(s),
StringType()),
"UDF command, closure over a dynamic class": (udf_with_model,
DoubleType()),
"RDD batch, 1,024 ints": list(range(1024)),
"RDD batch, 1,024 (k, v) tuples": [(i % 100, i) for i in
range(1024)],
"RDD batch, 1,024 dicts": [{"id": i, "name": str(i)} for i in
range(1024)],
"RDD batch, 1,024 dynamic class instances": [Point(i, -i) for i in
range(1024)],
}
results = {}
for name, obj in cases.items():
payload = ser.dumps(obj)
results[name] = {
"dumps_us": best_of(lambda: ser.dumps(obj)),
"loads_us": best_of(lambda: pickle.loads(payload)),
"bytes": len(payload),
}
return {"cloudpickle": cloudpickle.__version__, "results": results}
def best_of(fn, repeat=9):
timer = timeit.Timer(fn)
loops, _ = timer.autorange()
gc.collect()
return min(timer.repeat(repeat=repeat, number=loops)) / loops * 1e6
def e2e():
from pyspark import cloudpickle
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
from pyspark.sql.functions import udf
spark = (
SparkSession.builder.master("local[4]")
.config("spark.ui.enabled", "false")
.config("spark.sql.shuffle.partitions", "8")
.config("spark.driver.memory", "4g")
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sc = spark.sparkContext
model = Model(100)
add_one = udf(lambda x: x + 1, "long")
to_struct = udf(lambda x: (x, str(x)), "struct<a:long,b:string>",
useArrow=False)
def predict(x):
return model.predict(helper(x))
tuples = [(i, str(i)) for i in range(500_000)]
big_dict = {i: str(i) for i in range(1_000_000)}
def broadcast_job():
bv = sc.broadcast(big_dict)
total = sc.parallelize(range(8), 8).map(lambda i: len(bv.value) +
i).sum()
bv.destroy()
return total
cases = {
"Python UDF, Arrow-optimized (default), 2M rows": lambda:
spark.range(
0, 2_000_000, 1, 8
)
.select(F.sum(add_one("id")))
.collect(),
"Python UDF, pickled rows (useArrow=False), struct result, 500k
rows": lambda: spark.range(
0, 500_000, 1, 8
)
.select(F.sum(F.length(to_struct("id").b)))
.collect(),
"RDD map + reduceByKey of (k, v) tuples, 1M records": lambda:
sc.parallelize(
range(1_000_000), 8
)
.map(lambda x: (x % 1000, x))
.reduceByKey(operator.add)
.count(),
"RDD collect of dynamic class instances, 200k records": lambda:
sc.parallelize(
range(200_000), 8
)
.map(lambda x: Point(x, -x))
.collect(),
"RDD parallelize + collect of tuples, 500k records": lambda:
sc.parallelize(
tuples, 8
).collect(),
"RDD of 400 tasks, closure over a dynamic class": lambda:
sc.parallelize(range(400), 400)
.map(predict)
.count(),
"Broadcast of a 1M-entry dict, read by 8 tasks": broadcast_job,
}
results = {}
for name, job in cases.items():
for _ in range(2):
job()
times = []
for _ in range(7):
gc.collect()
start = time.perf_counter()
job()
times.append((time.perf_counter() - start) * 1000)
results[name] = {"ms": times}
spark.stop()
return {"cloudpickle": cloudpickle.__version__, "results": results}
if __name__ == "__main__":
mode, output = sys.argv[1], sys.argv[2]
data = micro() if mode == "micro" else e2e()
data["python"] = sys.version.split()[0]
data["pid"] = os.getpid()
with open(output, "w") as f:
json.dump(data, f)
print("wrote", output, data["cloudpickle"])
```
</details>
---
<!-- GITHUB_MCP_FOOTER: This attribution is automatically appended by GitHub
MCP. -->
_This comment was generated with [GitHub MCP](http://go/mcps)._
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]