Skip to content

[core][taskEvents out of GCS][2.5/n] Optimize ray task event recorder - #65288

Open
karticam wants to merge 8 commits into
ray-project:masterfrom
karticam:karticam/optimize-task-event-recorder
Open

[core][taskEvents out of GCS][2.5/n] Optimize ray task event recorder#65288
karticam wants to merge 8 commits into
ray-project:masterfrom
karticam:karticam/optimize-task-event-recorder

Conversation

@karticam

@karticam karticam commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

When running benchmarks on #64835 with flags on that trigger the ray task event recorder flow - namely:

enable_ray_event: true
enable_ray_task_event_recorder: true
enable_core_worker_task_event_to_gcs: false (GCS fed only via recorder->aggregator)

I found some regressions. This PR fixes those regressions.

There were 3 optimizations made to ray_task_event_recorder.
The 3 optimizations are applied as different commits in the PR (added below with the explanations). For easier review, go through the commits separately.

Optimizations:

  1. c6efc55 [opt1] : Earlier all of ExportEvents function operated under the mutex. The function would first process the status events and profile events along with skipped events to make form the object to send to aggregator agent. Then it would do group, merge and serialize those event and then send it to the aggregator agent. Given the max buffer size which could be 100k potentially, this could take a lot of time and holding the mutex for long could lead to other AddEvents request to be stalled. The fix is to release the mutex as soon as the shared queues are drained out in local queues and then do the rest of the processing.

  2. 005e5d7 [opt2]: Earlier, the ray event protos for task status events (RayTaskDefinitionEvent, RayActorTaskDefinitionEvent and RayTaskLifecycleEvent) were made in the hot task execution path. This commit moves it to the export part. Since export runs on on a different thread, it doesn't hamper task execution.

  • For RayTaskDefinitionEvent and RayActorTaskDefinitionEvent, task event recorder's MergeData is a no-op and therefore, SerializeData can populate the event proto trivially (just call the populate function from SerializeData).
  • For 'RayTaskLifecycleEvent, we collate all the state updates in 'MergeData and then in SerializeData, apply each state update one by one.

Note that this thing has one caveat that now the proto formation happens in ExportEvents under the task event recorder mutex, while earlier they were out of the mutex (it used to happen before calling AddEvent on the proto). So this increases mutex contention.

To try to fix this, I tried to export less than the entire buffer at a time (say 10k out of the 100k buffer) so that there is less work under the mutex. But firstly, this led to poorer perf as compared to flushing the entire buffer and secondly to fully get it to work around all cases, some other changes were required in the recorder as well.

  1. 7a2b482 [opt3]: Earlier, even when task_event_buffer to GCS or aggregator flow was turned off, the current code did form the TaskStatusEvent and put it in the ring buffer under mutex. The fix made is that if ``task_event_buffer` is not publishing anywhere, short circuit that path very early.

Results:
Given below are the regression % after before any optimizations and after each optimization.

Benchmark recorder on (before opt) +opt1 +opt1,2 +opt1,2,3 (this PR) +opt1,2,3,4 ¹
1_1_actor_calls_async -39.3% -25.4% -17.1% -13.6% -9.5%
1_n_actor_calls_async -33.7% -20.3% -10.5% -4.5% +2.0%
1_n_async_actor_calls_async -30.8% -18.6% -14.3% -2.7% +1.8%
1_1_actor_calls_concurrent -29.1% -14.8% -13.9% -12.7% -10.3%
n_n_actor_calls_async -28.8% -18.1% -13.2% -11.2% -8.1%
n_n_async_actor_calls_async -27.6% -14.6% -8.6% -9.1% -7.0%
single_client_tasks_and_get_batch -26.8% -13.3% -13.9% -11.8% -4.6%
single_client_tasks_async -26.7% -14.1% -12.6% -9.2% -6.4%
1_1_async_actor_calls_async -25.1% -13.0% -16.6% -10.8% -9.1%
single_client_wait_1k_refs -19.9% -12.0% -9.7% -9.2% -7.8%
multi_client_tasks_async -17.9% -11.0% -7.8% -5.7% -4.0%
1_1_async_actor_calls_with_args_async -15.4% -9.5% -9.2% -6.3% -6.0%
1_1_actor_calls_sync -14.8% -6.9% -10.0% -4.5% -4.2%
1_1_async_actor_calls_sync -14.5% -6.8% -9.1% -3.9% -5.0%
single_client_tasks_sync -6.3% -3.2% -3.4% -3.2% -1.7%
Mean -23.8% -13.4% -11.3% -7.9% -5.3%
Worst -39.3% -25.4% -17.1% -13.6% -10.3%

There are two other optimizations that I tried which are not a part of this PR:

  1. [opt4]: cut per event overhead on the record path using some micro-optimizations like:
  • instead of keying by (task-id-binary-string, attempt) ,key using (TaskID, attempt) to prevent string allocations.
  • share the session name as a shared_ptr instead of copying it into each event.
  • hands events to the recorder one at a time instead of allocating a vector per record call.

But it changes several widely-used signatures (TaskAttemptId, GetSessionName, the record entry point). Since it gives ~2.5% gain and we are already below 8% I didn't include it in this PR.

  1. [opt5]: Right now whenever we AddEvents, if the ring is full, we would drop events. While dropping events, we record it using MetricInterface which does some allocations etc. holding a mutex. This could increase contention over the mutex. I tried to batch the drops and then export them to the metric only during ExportEvents. This gave some perf gains (when I was trying flushing only 10k events per batch), but then it hampers metric fidelity since they are now stale between flushes.

@karticam
karticam requested a review from a team as a code owner August 7, 2026 16:07
@karticam karticam added core Issues that should be addressed in Ray Core go add ONLY when ready to merge, run all tests labels Aug 7, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the task event recording and serialization logic. It extracts task event population helpers and the TaskStateUpdate struct into a separate observability library, and defers protobuf serialization for definition and lifecycle events to the export path, keeping it off the task's critical path. Additionally, it optimizes the task event buffer to only record events when at least one destination is active. The review feedback suggests several improvements: replacing std::make_move_iterator on a const reference with standard iterators, adding defensive null checks for task_spec in event constructors, replacing non-standard std::optional<const T> types with const std::optional<T>&, and simplifying implicit std::optional construction in unit tests.

Comment on lines +50 to +52
definition_event_data.mutable_required_resources()->insert(
std::make_move_iterator(required_resources.begin()),
std::make_move_iterator(required_resources.end()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since required_resources is a const reference, std::make_move_iterator cannot actually move the elements and will fall back to copying them. Using std::make_move_iterator on const iterators is misleading and can cause compiler warnings. We should use standard iterators instead.

Suggested change
definition_event_data.mutable_required_resources()->insert(
std::make_move_iterator(required_resources.begin()),
std::make_move_iterator(required_resources.end()));
definition_event_data.mutable_required_resources()->insert(
required_resources.begin(),
required_resources.end());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to be a valid comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. done

task_spec_(std::move(task_spec)),
task_id_(task_id),
job_id_(job_id),
task_attempt_(task_attempt) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For defensive programming, we should verify that task_spec is not null before storing and dereferencing it, as dereferencing a null shared_ptr leads to undefined behavior.

      task_attempt_(task_attempt) {
  RAY_CHECK(task_spec_ != nullptr);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

task_spec_(std::move(task_spec)),
task_id_(task_id),
job_id_(job_id),
task_attempt_(task_attempt) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For defensive programming, we should verify that task_spec is not null before storing and dereferencing it, as dereferencing a null shared_ptr leads to undefined behavior.

      task_attempt_(task_attempt) {
  RAY_CHECK(task_spec_ != nullptr);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

const JobID &job_id,
int32_t task_attempt,
rpc::TaskStatus task_status,
const std::optional<const TaskStateUpdate> &state_update,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using std::optional<const TaskStateUpdate> is non-standard and can lead to compilation issues on some compilers or standard library implementations, as std::optional is typically expected to hold non-const types. It also introduces inconsistency with task_event_populators.h which uses const std::optional<TaskStateUpdate>&. We should change this to const std::optional<TaskStateUpdate>&.

                        const std::optional<TaskStateUpdate> &state_update,

const JobID &job_id,
int32_t task_attempt,
rpc::TaskStatus task_status,
const std::optional<const TaskStateUpdate> &state_update,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using std::optional<const TaskStateUpdate> is non-standard and can lead to compilation issues on some compilers or standard library implementations, as std::optional is typically expected to hold non-const types. It also introduces inconsistency with task_event_populators.h which uses const std::optional<TaskStateUpdate>&. We should change this to const std::optional<TaskStateUpdate>&.

Suggested change
const std::optional<const TaskStateUpdate> &state_update,
const std::optional<TaskStateUpdate> &state_update,

struct TaskStateUpdate {
TaskStateUpdate() = default;

explicit TaskStateUpdate(const std::optional<const rpc::RayErrorInfo> &error_info)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using std::optional<const rpc::RayErrorInfo> is non-standard and can lead to compilation issues on some compilers or standard library implementations, as std::optional is typically expected to hold non-const types. We should change this to const std::optional<rpc::RayErrorInfo>&.

Suggested change
explicit TaskStateUpdate(const std::optional<const rpc::RayErrorInfo> &error_info)
explicit TaskStateUpdate(const std::optional<rpc::RayErrorInfo> &error_info)

job_id,
/*task_attempt=*/0,
rpc::TaskStatus::SUBMITTED_TO_WORKER,
std::optional<const TaskStateUpdate>(TaskStateUpdate(node_id, worker_id)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since std::optional can be implicitly constructed from its underlying type, we can simplify this by passing TaskStateUpdate directly without the explicit std::optional wrapper.

Suggested change
std::optional<const TaskStateUpdate>(TaskStateUpdate(node_id, worker_id)),
TaskStateUpdate(node_id, worker_id),

job_id,
/*task_attempt=*/0,
rpc::TaskStatus::RUNNING,
std::optional<const TaskStateUpdate>(TaskStateUpdate(static_cast<uint32_t>(4321))),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since std::optional can be implicitly constructed from its underlying type, we can simplify this by passing TaskStateUpdate directly without the explicit std::optional wrapper.

Suggested change
std::optional<const TaskStateUpdate>(TaskStateUpdate(static_cast<uint32_t>(4321))),
TaskStateUpdate(static_cast<uint32_t>(4321)),

job_id,
/*task_attempt=*/0,
rpc::TaskStatus::NIL,
std::optional<const TaskStateUpdate>(TaskStateUpdate(start_log)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since std::optional can be implicitly constructed from its underlying type, we can simplify this by passing TaskStateUpdate directly without the explicit std::optional wrapper.

Suggested change
std::optional<const TaskStateUpdate>(TaskStateUpdate(start_log)),
TaskStateUpdate(start_log),

job_id,
/*task_attempt=*/0,
rpc::TaskStatus::NIL,
std::optional<const TaskStateUpdate>(TaskStateUpdate(end_log)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since std::optional can be implicitly constructed from its underlying type, we can simplify this by passing TaskStateUpdate directly without the explicit std::optional wrapper.

Suggested change
std::optional<const TaskStateUpdate>(TaskStateUpdate(end_log)),
TaskStateUpdate(end_log),

}

if (data->events_size() == 0 && metadata->dropped_task_attempts_size() == 0) {
MarkGrpcDone();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR needs clearer description

Low Severity

⚠️ This PR needs a clearer title and/or description.

To help reviewers, please ensure your PR includes:

  • Title: A concise summary of the change
  • Description:
    • What problem does this solve?
    • How does this PR solve it?
    • Any relevant context for reviewers such as:
      • Why is the problem important to solve?
      • Why was this approach chosen over others?

See this list of PRs as examples for PRs that have gone above and beyond:

Fix in Cursor Fix in Web

Triggered by project rule: Bugbot Rules

Reviewed by Cursor Bugbot for commit 414d343. Configure here.

Comment thread src/ray/observability/task_event_populators.h
@karticam
karticam force-pushed the karticam/optimize-task-event-recorder branch from 414d343 to 7a2b482 Compare August 7, 2026 17:56

@MengjinYan MengjinYan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done with opt1. Keeping looking at other opts

const TaskAttemptId &attempt)
ABSL_EXCLUSIVE_LOCKS_REQUIRED(mutex_);

// Move the buffered events, and the dropped task attempts to report, out of the buffers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Use doxygen format for function comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

// so that the caller can group, serialize and send them without holding mutex_. Events
// belonging to a dropped attempt are discarded so that a task attempt is either sent in
// full or not at all.
void TakeEventsToSend(std::list<std::unique_ptr<RayEventInterface>> *events,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Prefer a return structure than output parameters.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

return;
}
// Skip if there's already an in-flight gRPC call to avoid overlapping requests.
if (grpc_in_progress_.exchange(true)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that with the change, the behavior of when the grpc_in_progress_ is set is changed as well. Wondering is it the expected behavior? The same logic is in ray_event_recorder as well. So we might want to consolidate the logic for both places.

@MengjinYan MengjinYan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done with opt 2 and its fix. Moving on to opt 3

: is_debugger_paused_(is_debugger_paused) {}

/// Node id if it's a SUBMITTED_TO_WORKER status change.
std::optional<NodeID> node_id_ = std::nullopt;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: struct data members shouldn't have trailing underscore, per google c++ style guide.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +50 to +52
definition_event_data.mutable_required_resources()->insert(
std::make_move_iterator(required_resources.begin()),
std::make_move_iterator(required_resources.end()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to be a valid comment


std::string RayActorTaskDefinitionEvent::GetEntityId() const {
return data_.task_id() + std::to_string(data_.task_attempt());
return task_id_.Binary() + std::to_string(task_attempt_);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer absl::StrCat() for string concatenation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add tests for actor definition event as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. done

@MengjinYan MengjinYan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opt3 looks good. Since the new task event recorder will replace the task event buffer in the near future, it's okay for now to add an additional boolean field in the class.

Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
@karticam
karticam force-pushed the karticam/optimize-task-event-recorder branch from 7a2b482 to 9edc5ae Compare August 8, 2026 01:42

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 9edc5ae. Configure here.

Comment thread src/ray/observability/ray_task_event_recorder.h Outdated
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>

@MengjinYan MengjinYan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All resolved comments looks good. One minor followup comment.


std::string RayTaskLifecycleEvent::GetEntityId() const {
return data_.task_id() + std::to_string(data_.task_attempt());
return task_id_.Binary() + std::to_string(task_attempt_);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: Prefer absl::StrCat here.

Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Issues that should be addressed in Ray Core go add ONLY when ready to merge, run all tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants