Sagi
11/07/2024, 3:27 PMdf = daft.from_pydict({"a": [1, 2, 3, 4], "b": [4, 3, 2 ,1]}).repartition(2)
with 2 non empty partitions and 2 columns.
When I run a dummy udf that simply sleeps for 2 seconds, I observe what seems to be four sequential udf calls, potentially disregarding resource allocation. All four udf calls share the same pid.
Here's the dummy udf I run:
@daft.udf(return_dtype=daft.DataType.int64())
def f_1s(s: daft.Series):
process_id = os.getpid()
task_id = np.random.randint(1, 1000)
print(f"start ---- pid {process_id} ---- task {task_id}")
sleep(2)
print(f"end ------ pid {process_id} ---- task {task_id}")
return s
the output is:
start ---- pid 25236 ---- task 705
end ------ pid 25236 ---- task 705
start ---- pid 25236 ---- task 70
end ------ pid 25236 ---- task 70
start ---- pid 25236 ---- task 166
end ------ pid 25236 ---- task 166
start ---- pid 25236 ---- task 888
end ------ pid 25236 ---- task 888
Can anyone explain why this happens, or suggest how I could enable parallel udf calls?
Edit:
It appears that this only applies when I retrieve the df with show instead of collect.
Is it possible to initiate parallel calls across columns?jay
11/07/2024, 6:14 PMfrom time import sleep
import numpy as np
import os
import daft
@daft.udf(return_dtype=daft.DataType.int64())
def f_1s(s: daft.Series):
process_id = os.getpid()
task_id = np.random.randint(1, 1000)
print(f"start ---- pid {process_id} ---- task {task_id}")
sleep(2)
print(f"end ------ pid {process_id} ---- task {task_id}")
return s
df = daft.from_pydict({"a": [1, 2, 3, 4], "b": [4, 3, 2 ,1]}).repartition(2)
df = df.with_column("c", f_1s(df["a"]))
df.show()
And observed only 2 tasks (which is expected):
/Users/jaychia/code/Daft/daft/dataframe/dataframe.py:1665: UserWarning: No columns specified for repartition, so doing a random shuffle. If you do not require rebalancing of partitions, you may instead prefer using `df.into_partitions(N)` which is a cheaper operation that avoids shuffling data.
warnings.warn(
LocalLimit-LocalLimit-FanoutRandom [Stage:2]: 0%| | 0/1 [00:00<?, ?it/sstart ---- pid 65652 ---- task 7500%| | 0/1 [00:00<?, ?it/s]
end ------ pid 65652 ---- task 750
start ---- pid 65652 ---- task 7550%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:02<00:00, 2.01s/it]
end ------ pid 65652 ---- task 755
╭───────┬───────┬───────╮
│ a ┆ b ┆ c │
│ --- ┆ --- ┆ --- │
│ Int64 ┆ Int64 ┆ Int64 │
╞═══════╪═══════╪═══════╡
│ 3 ┆ 2 ┆ 3 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 1 ┆ 4 ┆ 1 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 2 ┆ 3 ┆ 2 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 4 ┆ 1 ┆ 4 │
╰───────┴───────┴───────╯
(Showing first 4 of 4 rows)
1. The UDFs run on the same PID because our local runner runs on multithreading. By default we run the same number of threads as there are CPUs.
a. This does mean that our stateless UDFs are susceptible to GIL contention.
b. In v0.4, our “stateful UDFs” will allow a mode where users can specify a .with_concurrency. This will instead spin up N number of processes (each representing one replica of an instantiated instance of the UDF’s class) and we won’t be limited by the GIL.
i. The downside here is of course the overhead of passing data back-and-forth between processes, and also process initialization time.
2. Interestingly, if I run this on swordfish, there is only 1 “task”. This is because Swordfish figures out its own notion of parallelism based on the morsels I believe.jay
11/07/2024, 6:32 PMdf = df.with_column("a_prime", f_1s(df["a"]))
df = df.with_column("b_prime", f_1s(df["b"]))
df.show()
Like this?
We don’t do this today, and even our normal expressions don’t get called in parallel either I believe (e.g. if you call .str.lower() on both columns, they get executed serially)
Moreover, with the GIL and multithreading we’re likely to see contention here as well which would prevent “true” parallelism.
We expect stateless UDFs to not be performance-sensitive and more of a convenient way to run Python on the dataframes.
---
Things get a little more interesting when you get to our new stateful UDFs. We actually spin up two different process pools, one for each UDF. We can then get pipeline parallelism across these (they can be executing on different partitions in parallel). Here’s an example (you have to run this with the environment variable DAFT_ENABLE_ACTOR_POOL_PROJECTIONS=1)
from time import sleep
import numpy as np
import os
import daft
@daft.udf(return_dtype=daft.DataType.int64())
class F:
def __init__(self, udf_id: str):
self.udf_id = udf_id
def __call__(self, s: daft.Series):
process_id = os.getpid()
task_id = np.random.randint(1, 1000)
print(f"start ---- udf_id {self.udf_id} ---- pid {process_id} ---- task {task_id}")
sleep(2)
print(f"end ------ udf_id {self.udf_id} ---- pid {process_id} ---- task {task_id}")
return s
# Set the number of replicas per F
F = F.with_concurrency(1)
F1 = F.with_init_args("F1")
F2 = F.with_init_args("F2")
df = daft.from_pydict({"a": [1, 2, 3, 4], "b": [4, 3, 2 ,1]}).repartition(2)
df = df.with_column("a_prime", F1(df["a"]))
df = df.with_column("b_prime", F2(df["b"]))
df.show()
You’ll notice here when executing that we get pipelined parallelism between F1 and F2! Also note the PID is different, because we’re running them on separate process pools.
start ---- udf_id F1 ---- pid 75060 ---- task 796
end ------ udf_id F1 ---- pid 75060 ---- task 796
start ---- udf_id F1 ---- pid 75060 ---- task 499
start ---- udf_id F2 ---- pid 75079 ---- task 4
end ------ udf_id F1 ---- pid 75060 ---- task 499
end ------ udf_id F2 ---- pid 75079 ---- task 4
start ---- udf_id F2 ---- pid 75079 ---- task 688
end ------ udf_id F2 ---- pid 75079 ---- task 688
The physical plan also tells a better story of how this works:
== Physical Plan ==
* ActorPoolProject:
| Projection = [col(a), col(b), col(a_prime), pyclass_udf(col(b)) as b_prime]
| UDFs = [__main__.F]
| Concurrency = 1
| Clustering spec = { Num partitions = 2 }
| Resource request = None
|
* ActorPoolProject:
| Projection = [col(a), col(b), pyclass_udf(col(a)) as a_prime]
| UDFs = [__main__.F]
| Concurrency = 1
| Clustering spec = { Num partitions = 2 }
| Resource request = None
|
* ShuffleExchange:
| Strategy: NaiveFullyMaterializingMapReduce
| Target Spec: Random(RandomClusteringConfig { num_partitions: 2 })
| Number of Partitions: 1 → 2
|
* InMemoryScan:
| Schema = a#Int64, b#Int64,
| Size bytes = 64,
| Clustering spec = { Num partitions = 1 }
Note that there are 2 ActorPoolProjects, each will spin up its own process pool and run partitions in a pipelined manner!Sagi
11/12/2024, 7:28 AMjay
11/12/2024, 8:56 PMI’ll just check to ensure that changing the multiprocessing start method from ‘fork’ to ‘spawn’ won’t cause materialized partitions to be duplicated in every process pool’s memory.This is a good point… @Kevin Wang we might want to check up on that. We don’t currently have plans for elementwise mapping!