[Data] RFC: build a map task's timing from a declared list of steps - #65855
Merged
marwan116 merged 1 commit intoSep 2, 2026
Merged
Conversation
marwan116
force-pushed
the
marwan/data-timed-steps-proto
branch
from
September 2, 2026 00:02
e646a18 to
6bcb4ee
Compare
8 tasks
Answers a review comment on #65807 asking for an approach that is explicit about what gets timed, rather than one where the timing emerges from nested lazy iterators. `MapTransformFn.timed_steps()` returns the transform as a flat list of `TimedStep`s -- label, phase, stage index, and an `Iterable -> Iterable` body. `MapTransformer.get_timed_steps()` concatenates them and `TransformClock.chain()` builds the pipeline, so the answer to "what is getting timed?" is a list that can be printed and asserted on. A step measures only itself, with no coordination: def __next__(self): start = time.perf_counter() try: if self._iter is None: self._iter = iter(self._apply(self._upstream)) return next(self._iter) finally: self._totals[self._idx] += time.perf_counter() - start That total is inclusive of everything upstream. The chain is linear -- step k is only ever pulled by step k+1 -- so all of a step's time lies inside its consumer's windows, and subtracting neighbouring totals at drain recovers each step's own work. #65807 does the same arithmetic per item, threading a shared cursor through every `__next__`; doing it once over a list is easier to follow and measures 580 ns/item against 1016. Deferring `apply` to the first pull removes the eager special case. A write performs its whole upload while its iterable is built, which on #65807 needed its own timing window (`attribute_call`); here that work simply happens inside the same window as the pulls. `accurate_map_phase_timing` stops being a second code path and becomes a rewrite of the step list: fold a stage's three steps into one, whose `bucket` is None because it spans all three. A phase no step carries is then absent from the grouping, so "not measured" falls out of the data rather than being tracked by a flag. Accruing per step also means the per-phase and per-fused-stage breakdowns are two groupings of one array, with no second accumulator to keep in sync. `UDFTimeScope` becomes `TransformClock` and the `udf_time_scope=` keyword becomes `clock=`; the old name stopped describing an object that times every step, including the ones that are not user code. Removes `PhaseWrapFn`, `_no_phase_wrapping`, `wrap_phase`, `attribute_call` and `_UDFTimingIterator`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marwan Sarieddine <sarieddine.marwan@gmail.com>
marwan116
force-pushed
the
marwan/data-timed-steps-proto
branch
from
September 2, 2026 02:49
6bcb4ee to
244dc5b
Compare
marwan116
merged commit Sep 2, 2026
3c55985
into
marwan/data-per-stage-map-timing
3 of 4 checks passed
marwan116
added a commit
that referenced
this pull request
Sep 2, 2026
…nding Cursor Bugbot caught this on the merge of #65855. `TransformClock.chain` hands every `_TimedStep` a reference to `self.inclusive`, and `drain` replaced that list with a new one -- so after the first drain the steps kept adding to a list the clock no longer read. `_map_task` drains once per output block, so a task's first block reported its time and every block after it reported zero. Zeroing the list in place keeps the steps and the clock pointing at the same object. The per-drain window is still self-contained: both a step and its upstream neighbour start from zero at the same instant, so the adjacent-difference arithmetic is unchanged. Unit-level, one stage sleeping 0.05s with one output block per batch: before block 0: 0.0573s block 1: 0.0000s block 2: 0.0000s after block 0: 0.0826s block 1: 0.0537s block 2: 0.0548s `test_every_output_block_is_timed` covers it, and fails on the old line. Signed-off-by: Marwan Sarieddine <marwan@anyscale.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FfAW6hXHCMinYkJ3W7nfBa Signed-off-by: Marwan Sarieddine <sarieddine.marwan@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why these changes are needed
The review comment:
That is fair, and it is the root of the subtlety in #65807: timing is emergent from the pull-chain rather than declared. You have to simulate the chain in your head to know what a number means.
What this change does
A task's transform becomes a flat list of steps.
_pre_processand_post_processare alreadyIterable -> Iterable, so they are the step bodies verbatim; only the wrapped fn needs a closure to bind the task context.apply_transformthen reduces to:A step measures only itself, with no coordination.
That total is inclusive of everything upstream. The chain is linear -- step
kis only ever pulled by stepk + 1-- so all of a step's time lies inside its consumer's windows, and subtracting neighbouring totals recovers each step's own work:#65807 does the same arithmetic, but per item, threading a shared cursor through every
__next__. Doing it once over a list at drain time is easier to follow and measurably cheaper: 580 ns/item against 1016 over a three-stage chain.Deferring
applyto the first pull removes the eager special case. A write performs its whole upload while its iterable is being built, which on #65807 needed a separate timing window (attribute_call). Here that work happens inside the same window as the pulls, so there is one timing point rather than two.Two special cases dissolve
attribute_callfor these. Deferringapplyinto the first pull means there is nothing left to special-case -- the reviewer's second side note, satisfied by deletion rather than by handling.accurate_map_phase_timingstops being a second code path and becomes a rewrite of the step list -- fold a stage's three steps into one, whosebucketisNonebecause it spans all three. A phase no step carries is then absent from the grouping, so "not measured" falls out of the data instead of being tracked by a flag. This is the same shape as the pre/post-process elision suggested in the follow-up comment, which would be another rewrite of the same list. Policy becomes data.UDFTimeScopeis renamed toTransformClock, and theudf_time_scope=keyword toclock=(41 call sites across 10 files). The old name no longer described the object: it times every step in the chain, including the ones that aren't user code, which is the same mismatch raised here.Accruing per step rather than per phase also unifies #65807 and #65810: per-phase and per-fused-stage are two groupings of one array. #65810's separate
stage_totalsand its parallel+=would disappear -- along with the bug a rebase surfaced there, where the eager window credited the phase bucket but silently skipped the stage bucket. This PR doesn't carry that grouping, since #65810 isn't in its base.Where the reviewer's sketch does not work, and why
The suggestion was an eager per-input-block loop. Two measurements say that cannot express what Ray Data already does.
Batches span input blocks. 4 input blocks of 1 row each with
batch_size=4:A single fused operator can contain several independent batching points:
g's batches of 3 are re-assembled fromf's outputs of 2, inside one task. No unit -- not a block, not a batch -- survives the chain intact, sofor input_block in blocks:cannot be the outer loop; it would silently turnbatch_size=4into four batches of 1. The only eager alternative materializes at every stage, trading streaming for memory.So laziness is structural. This PR keeps it and makes the timing declared instead.
Measured equivalence with #65807
ReadRange->Project->MapBatches(map1)->MapBatches(map2), 0.60s of real UDF workFunction body 614.3ms611.03msBuilt-in stages 606ms(viaattribute_call)602.9ms, no special caseReadRange(no UDF)0.00UDF timevsRemote wall timeIsolated unit check of the baton -- three steps, only the middle one sleeping, 4 items x 50ms:
Exact attribution, no leakage into neighbouring buckets.
Per-item cost is unchanged: over a 3-stage chain, 1019 ns/item for the baton against 1016 ns/item for the subtraction cursor in #65807 (31 ns/item with no timing at all). The 33x over baseline is inherent to per-item timing, which is why
accurate_map_phase_timingstill has to exist for row transforms.Testing
PYTHONPATH=python/ray/data/tests pytest python/ray/data/tests/test_stats.py pytest -q python/ray/data/tests/test_map_transformer.py python/ray/data/tests/unit/test_auto_batch_size.py PYTHONPATH=python/ray/data/tests pytest python/ray/data/tests/test_checkpoint.py -k "transform or checkpoint_map"test_stats.py: 108 collected, 105 passed, 2 skipped, 1 failed — the failure beingtest_streaming_exec_schedule_percentiles_populated, which fails identically on #65807 because the dashboard is not built locally. That matches #65807's baseline exactly, withtest_stats.pyunmodified: every test written for the phase breakdown there passes against this implementation untouched, which is the main evidence that this is behaviour-preserving.test_map_transformer.py+unit/test_auto_batch_size.py: 9 passed.test_checkpoint.py(the cases that callapply_transformdirectly): 2 passed.One test change was needed, and it is an API change rather than a behavioural one.
test_chained_transforms_dont_double_count_udf_timeandtest_chained_transforms_total_is_independent_of_distributionreadscope.attributed_s, the running total this PR removes; they now readsum(scope.totals). Both are #65776's core invariant tests, and both pass with only the accessor changed.One behaviour change worth reviewing
A transform's body used to run when the chain was built; it now runs on the first pull:
So a transform that fails while being set up rather than while yielding now surfaces from the first
next()instead of fromapply_transform. That is safe where it matters, becauseiterate_with_retry-- which_map_taskwraps the transform in -- puts construction and iteration inside the sametry:so
retried_map_errorsandmax_map_retriesbehave identically either way.It also removes a hazard rather than adding one. Today
_pre_processruns eagerly at construction, so withbatch_size="auto"a later stage peeks at a real block and pulls data through earlier stages before their timers exist -- the regression Cursor Bugbot caught on #65776, and the reason that PR must install each stage's timer before building the next. When nothing runs until the first pull, every timer is already in place and that class of ordering bug cannot occur.Open questions
MapTransformFns into one (is_udf = self._is_udf or next._is_udf), so one step can correspond to two logical operators.TimedStep.labelwould need care there.Built-in stagesremain its own bucket? The review suggests merging built-in stages into UDFs. Under this design that is a one-line change, since every step carriesis_udf. I lean towards keeping the split -- "is this time my code or Ray Data's?" is the actionable question for the workload that motivated [Data] Fix double-counted UDF time in fused map operators #65776 -- but the architecture no longer forces the answer either way.TimedStep.labelwould let [Data] Report UDF time per fused stage, behind a flag #65810 reportMapBatches(score)instead ofUDF stage 0, fixing a limitation documented there.Related issue number
Follow-up to #65776 (merged), #65807 and #65810. No open issue or PR covers this;
gh pr list --search "map transformer timing"returns only #65807 and #65810.Checks
git commit -s).scripts/format.shto lint the changes in this PR.AI assistance
AI assistance (Claude Code) was used to prototype this approach, run the measurements above, and draft this description. Per
AGENTS.mdthe author has reviewed every changed line. This is an RFC opened for design discussion rather than a merge request.