Repository navigation
perf: create a native plan's spill directories on its first spill - #7066
Conversation
| let key = (local_dirs, max_temp_directory_size); | ||
| if let Some(disk_manager) = managers.get(&key) { | ||
| return Ok(Arc::clone(disk_manager)); | ||
| } |
There was a problem hiding this comment.
Thanks for tracking this down, the profile in #7060 is convincing. One thing I'd like to understand is what happens if the datafusion-* directory disappears while the executor is running, for example because a tmp cleaner removes it. On 1.1 every plan recreated its own directory, so the next plan healed. Here the TempDir is created once, so I think every later create_tmp_file fails with ENOENT until the executor restarts. Could we check that the directory still exists when handing out the cached manager and rebuild it if not? Or is there a reason that can't happen? Even a small test that removes the directory and then requests a manager would settle it
There was a problem hiding this comment.
DiskManager has set_max_temp_directory_size(&self, ..). Is there a reason to key the cache on the limit instead of keying on the dirs and setting the limit on the cached manager? As written, two sessions with different limits in one JVM get separate counters and separate directories. In production that can't happen, but it does in local-mode tests.
There was a problem hiding this comment.
I think keying on the limit is the safer of the two here. set_max_temp_directory_size stores into a shared AtomicU64, and FileSpillWriter::write reads max_temp_directory_size() again on every write. If the cache were keyed on the dirs only and each plan set its own limit on the cached manager, the last plan created would set the limit for every plan already spilling in that executor. A session running with a 500 GB limit could hit a 10 GB cap in the middle of a spill because another session just started a plan.
I'm also not sure this is limited to local-mode tests. The value comes from SQLConf.get, which on an executor reads the session confs that travel with the task. A Thrift server or Spark Connect app runs many sessions in one application, so different sessions can set different values and send tasks to the same executor. That is why I suggested in the comment on line 1016 that the docs say the limit is shared by plans that use the same value, rather than changing the keying.
There was a problem hiding this comment.
There is no cached manager anymore. Each plan creates its own directory again, now on its first spill rather than when it is built, so a directory that a tmp cleaner removes only affects the plans that already spilled into it, as on main.
There was a problem hiding this comment.
There is no cache to key now. Each plan builds its own DiskManager with its session's limit on its first spill, so one session's limit can't reach another session's plans.
| }) | ||
| } | ||
|
|
||
| #[test] |
There was a problem hiding this comment.
The test confirms the manager and its directory are shared. Could we also add one that shows the limit is shared? For example, create spill files through two create_tmp_file calls on the same manager, write past a small limit across both, and check the second write fails and used_disk_space() drops back after the files are dropped. That is the behavior change users will see, so it deserves a test of its own.
There was a problem hiding this comment.
The limit is per plan again, so the test is now each_plan_has_its_own_spill_directories_and_size_limit: two plans with a 10-byte limit each write 8 bytes, a second 8-byte write in the first plan fails, and the second plan's write still succeeds. It also checks that the two plans get separate directories.
viirya
left a comment
There was a problem hiding this comment.
Thanks for tracking this down, and for documenting the change to spark.comet.maxTempDirectorySize instead of letting it slip in silently. I checked the DataFusion 55.2 side. RefCountedTempFile::drop subtracts a file's size on its last reference, so in the normal path a long-lived DiskManager does not accumulate usage. The native shuffle writer's spill also goes through runtime.disk_manager, so the docs are right that shuffle shares the limit.
A few things need to be addressed before this can go in.
The Spark 4.1 [exec] job fails on CometNativeSuite "Comet native should use spark local dir as temp dir". That test asserts the datafusion-* directory is gone after the job, and this PR intentionally keeps it. The map is a static, so Comet never removes the directory. Spark removes it along with the blockmgr dir when the executor stops. With the external shuffle service enabled, it stays until the application ends. Could the test be updated to the new contract, for example that the directory is created once per local dir and holds no spill files after the job? Its first assertion, that no datafusion-* directory exists yet, also becomes order-dependent once any earlier test in the same JVM has run a native plan.
The benefit and the alternative. The 4.7% in #7060 was measured on macOS with 1 MB splits, and the issue notes these syscalls are cheaper on Linux. Do you have numbers on Linux with a typical split size? This PR trades a user-visible semantic change and process-wide state for that gain. The issue's own preferred fix is to create the directories lazily in DataFusion, the way OsTmpDirectory already does. That keeps the per-plan limit and needs no global map. Has an upstream issue been filed? If that fix lands, would Comet switch back to a per-plan limit? That would change the semantics a second time for users.
| /// dropped, so a long-lived `DiskManager` does not accumulate them. | ||
| /// | ||
| /// The size limit then covers all plans sharing the manager, not each plan separately. | ||
| fn shared_disk_manager( |
There was a problem hiding this comment.
A shared, never-dropped manager also makes DataFusion's accounting leaks permanent. In 55.2, FileSpillWriter::write adds len to used_disk_space before write_all, and it does not roll that back when the write fails. The per-file usage is not updated either, so Drop subtracts too little. This is apache/datafusion#24230, which is still open. With a per-plan manager, the leaked bytes went away with the plan. Here, every failed spill write permanently lowers the executor's spill budget until restart. ENOSPC on a shared local disk is exactly the case the docs say this limit protects against. Should this wait for the upstream fix, or at least link it in this comment so the next reader knows the limit can drift?
There was a problem hiding this comment.
The managers are per plan again, so drift from apache/datafusion#24230 goes away with the plan, as on main.
| let mut managers = DISK_MANAGERS | ||
| .get_or_init(|| Mutex::new(HashMap::new())) | ||
| .lock(); | ||
| let key = (local_dirs, max_temp_directory_size); |
There was a problem hiding this comment.
spark.comet.maxTempDirectorySize reaches here from SQLConf.get, so it is a session-level value. Two sessions that set different values, for example on a Thrift server, get separate managers, and each has its own full budget. Every distinct value also leaves a manager, plus one datafusion-* directory per local disk, in this map for the life of the executor. Keying on the limit is the right call given the new semantics. But it means the executor-wide bound only holds when every session uses the same value. Could the docs say that? See the comment on CometConf.scala.
There was a problem hiding this comment.
No map now, so nothing outlives its plan, and each plan uses the value its own session sent.
| "executor running N concurrent tasks may use more than N times this value on shared " + | ||
| "local disks. Once the limit is reached, further spills will fail and the query will " + | ||
| "error out.") | ||
| "used by native operators when spilling. Shared by all Comet native plans running " + |
There was a problem hiding this comment.
Because of the per-session keying in shared_disk_manager, "bounds the spill files of the whole executor" is only true when every session uses the same value. Maybe something like "Shared by all Comet native plans in an executor that use the same value of this config"?
There was a problem hiding this comment.
Reverted to main's text, since the limit applies to each native plan separately again.
| fail and the query errors out. Raise this on workloads with large sort/aggregate/shuffle spills, or | ||
| lower it to protect executors on shared disks, remembering that the total across an executor is a | ||
| multiple of this value. | ||
| `spark.comet.maxTempDirectorySize` (default 100 GB). All Comet native plans in an executor share |
There was a problem hiding this comment.
1.1 documents this limit as per plan, and the default stays at 100 GB. So an executor whose concurrent tasks together spill more than 100 GB will fail a query that succeeded on 1.1. When that happens, the error comes from DataFusion and tells the user to raise datafusion.runtime.max_temp_directory_size, which is not a Comet config. Would it make sense to call this out as an upgrade note, and to point users at spark.comet.maxTempDirectorySize when they hit that error? Was a larger default considered, now that the value covers the whole executor?
There was a problem hiding this comment.
Reverted with the rest of the semantic change, so there is no upgrade note to add and the default can stay. The DataFusion error that names datafusion.runtime.max_temp_directory_size is misleading on main too; I opened #7091 to point users at spark.comet.maxTempDirectorySize.
Replace the executor-wide shared DiskManager with a per-plan one whose Directories mode DiskManager is built through DataFusion's TempFileFactory hook on the plan's first spill. A plan that never spills no longer creates or removes a datafusion-* directory on every local disk, and spark.comet.maxTempDirectorySize stays a per-plan limit, so the config and tuning docs go back to their previous text. Update the CometNativeSuite temp dir test: a plan that does not spill creates no temp dir, a sort that spills creates one in a Spark local dir, and both are gone once the plans are released.
|
I switched to the alternative you pointed to, done in Comet rather than waiting for DataFusion. DataFusion 55.2 already lets an engine supply its own spill backend (
Linux numbers, from a Ryzen 7950X3D on Ubuntu 22.04 (kernel 6.8, ext4 on NVMe):
With one local disk at a typical split size the gain is in the noise, as you expected. It shows up for many short tasks on executors with several local disks, which YARN and Kubernetes nodes often configure. On EBS-backed local dirs I'd expect the same CPU cost, since the loop above runs at 95% CPU in the kernel rather than waiting on the device, plus two costs that grow there: the waits behind other writers' journal commits get longer on a volume that flushes at gp3's 125 MB/s baseline, and the churn's metadata writes (about 150 small writes/s at 3k plans/s with 8 dirs here) come out of the volume's IOPS budget. I haven't measured on EBS. With no semantic change or process-wide state left, I think the factory is worth keeping for that case. |
viirya
left a comment
There was a problem hiding this comment.
Thanks for reworking this. Going through the TempFileFactory keeps every behavior from main that I was worried about. I read the 55.2 path: the factory hands back the inner Directories manager's RefCountedTempFile, so the per-plan limit, the usage accounting on drop, and the directory cleanup are all the same as before. Nothing outlives the plan anymore. The Linux numbers are useful too. They make clear where the gain shows up, which is many short tasks on executors with several local disks.
The failed Spark SQL Tests (Spark 4.1) sql_core-2 job never ran a test. sbt could not download org.scala-sbt:sbt:1.11.7 after three attempts. A rerun should settle it, and that shard is the one that covers this path.
One small comment inline about the outer manager.
| builder, | ||
| disk_manager: Mutex::new(None), | ||
| })) | ||
| .with_max_temp_directory_size(max_temp_directory_size) |
There was a problem hiding this comment.
The outer DiskManager only carries the factory now. Its own used_disk_space and spilling_progress() stay at 0, and its max_temp_directory_size is never checked, because the inner manager enforces the limit. Nothing reads those in 55.2 today, so this is correct. But a later change that calls RuntimeEnvBuilder::with_max_temp_directory_size, or reads spill usage from RuntimeEnv, would silently get the wrong answer. Could the doc comment on plan_disk_manager say that the inner manager holds the limit and the accounting? A link to apache/datafusion#26446 there would also tell the next reader when the factory can go.
kazuyukitanimura
left a comment
There was a problem hiding this comment.
The description says the existing shuffle writer spill tests pass through the new path. The Rust ones I found (spilling_every_partition_creates_one_file and the tests in spill.rs) build their own DiskManager, so I don't think they reach plan_disk_manager. Is there a Scala test where the native shuffle writer spills? If so, could you name it? If
not, could you add a small test in spill_dirs.rs or the shuffle crate that gets a spill file through plan_disk_manager the way ensure_spill_file_created does? It should check that path() is Some, that bytes written count against the plan's limit, and that the file is removed on drop. Shuffle spill is the one consumer here that keeps the file
open across batches, so I'd like it covered before merge.
| builder, | ||
| disk_manager: Mutex::new(None), | ||
| })) | ||
| .with_max_temp_directory_size(max_temp_directory_size) |
There was a problem hiding this comment.
Thanks for reworking this. Going through TempFileFactory keeps the limit, the accounting and the cleanup exactly as on main. I agree with viirya's comment here. I'd add two sentences to this doc comment. One should say that the outer manager only carries the factory, so its used_disk_space and spilling_progress() stay at 0 and its
max_temp_directory_size is never checked. The second should say that the inner manager holds the limit and the accounting. A link to apache/datafusion#26446 would also tell the next reader when the factory can be removed. I checked 55.2 and nothing enforces on the outer manager today. The only readers I found are RuntimeEnv's reporting code at
runtime_env.rs:229 and :289. A later RuntimeEnvBuilder::with_max_temp_directory_size call would silently do nothing, though.
Which issue does this PR close?
Closes #7060.
Rationale for this change
Every native plan builds its own
RuntimeEnv, and with it aDiskManagerinDiskManagerMode::Directoriesover the executor's local disks. DataFusion 55.2 creates that mode'sdatafusion-*directory in every configured directory when theDiskManageris built and removes it recursively when it is dropped, so each plan pays for it whether or not it ever spills. #7060 measured these syscalls at 4.7% of task and Tokio thread samples for a small-task workload on macOS.On Linux (ext4 on NVMe) each local dir costs 11 syscalls and about 35 µs per plan, so 8 local dirs cost about 340 µs. The work is kernel CPU rather than device IO, but it serializes on the filesystem: with one local dir, 8 or 16 threads creating plans at once top out near 30k plans per second. It also queues behind other writers' journal commits: while another writer flushed about 0.7 GB/s to the same filesystem, 8 threads with 4 local dirs saw p99 per plan go from 1.1 ms to 22 ms. End to end, on Spark 4.1.1
local[8]with 500 small files (501 tasks), 8 local dirs made the query 12% slower in wall time and 9% in CPU. With 1 local dir, or at the default split size, the difference is in the noise.What changes are included in this PR?
spill_dirs::plan_disk_managergives each native plan aDiskManagerwith aTempFileFactory(DiskManagerMode::Custom). On the plan's first spill file, the factory builds the plan'sDirectoriesmodeDiskManager, and it creates every spill file through it. A plan that never spills creates no directory. A plan that spills gets the same directories, the samespark.comet.maxTempDirectorySizelimit and the same cleanup as before, so the limit still applies to each plan separately.The first version of this PR shared one
DiskManagerper executor instead, which made the limit executor-wide; review moved it to this approach. apache/datafusion#26446 proposes the same lazy creation in DataFusion, after which the factory can go.How are these changes tested?
spill_dirs.rs: the directories are created on the first spill file, reused by later ones and removed once the plan and its files are dropped; each plan gets its own directories and size limit.CometNativeSuite"Comet native should use spark local dir as temp dir" now checks that a plan that does not spill creates no temp dir, that a sort that spills creates one in a Spark local dir, and that none remain once the plans are released. It fails against the eager behavior.