-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Emit Quent events for cudf-polars memory reservation. #24038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ | |
| from cudf_polars.dsl.ir import IR | ||
| from cudf_polars.quent._logging import QuentLogger | ||
| from cudf_polars.quent._types import ( | ||
| MemoryReservationRequest, | ||
| Operator, | ||
| Plan, | ||
| Port, | ||
|
|
@@ -481,6 +482,66 @@ def _emit_task_end_events( | |
| # We can't do it directly on the Task object, because that (seems to) | ||
| # break operator-level aggregation like duration_s. | ||
|
|
||
| def _emit_memory_reservation_events( | ||
| self, | ||
| quent_task: Task, | ||
| quent_ir_execution_context: QuentIRExecutionContext, | ||
| request: MemoryReservationRequest, | ||
| *, | ||
| requested_at: int, | ||
| satisfied_at: int, | ||
| ) -> None: | ||
| """ | ||
| Emit Quent events describing a single memory reservation. | ||
|
|
||
| The task enters ``Allocating`` when the reservation is requested and | ||
| exits once rapidsmpf satisfies it (or fails to), so the duration of | ||
| the ``Allocating`` state is how long the operator waited for memory. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| quent_task: Task | ||
| The reservation's Quent Task, from | ||
| :meth:`~cudf_polars.quent._types.Task.for_memory_reservation`. | ||
| quent_ir_execution_context: QuentIRExecutionContext | ||
| The Quent IR execution context, which binds the reservation to the | ||
| operator that made it. | ||
| request: MemoryReservationRequest | ||
| The size, memory tier and purpose of the reservation. | ||
| requested_at: int | ||
| Timestamp (unix nanoseconds) at which the reservation was requested. | ||
| satisfied_at: int | ||
| Timestamp (unix nanoseconds) at which the reservation was satisfied. | ||
|
|
||
| Notes | ||
| ----- | ||
| This emits the following events: | ||
|
|
||
| - queueing | ||
| - allocating (with the Quent Processor for the current thread) | ||
| - exit | ||
|
|
||
| They're all built after the fact from recorded timestamps, so emitting | ||
| them doesn't inflate the wait we're trying to measure. | ||
|
Comment on lines
+524
to
+525
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove. |
||
|
|
||
| A reservation that induces spilling ought to pass through the Task's | ||
| ``Spilling`` state, but rapidsmpf doesn't report that back to us yet. | ||
|
Comment on lines
+527
to
+528
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove this too. I'll have a longer issue about tracking spilled operations. |
||
| """ | ||
| quent_processor = quent_ir_execution_context.get_or_declare_processor( | ||
| thread_ident=threading.get_ident(), | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe this should be a parameter? We're doing this at emit time, which in theory could be running on a different thread than the one that requested the memory reservation. But currently that's not an issue since we always create the reservation and then emit the events. |
||
| ) | ||
| quent_ir_execution_context.logger.emit( | ||
| quent_task.queueing(timestamp=requested_at) | ||
| ) | ||
| quent_ir_execution_context.logger.emit( | ||
| quent_task.allocating( | ||
| resource_id=quent_processor.id, | ||
| timestamp=requested_at, | ||
| reservation=request, | ||
| ) | ||
| ) | ||
| quent_ir_execution_context.logger.emit(quent_task.exit(timestamp=satisfied_at)) | ||
|
|
||
|
|
||
| @dataclasses.dataclass(kw_only=True) | ||
| class WorkerResources: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -881,6 +881,92 @@ def exit(self, timestamp: int | None = None) -> Event: | |
| ) | ||
|
|
||
|
|
||
| def _format_bytes(nbytes: int) -> str: | ||
| """Format a byte count compactly (e.g. ``256.0MiB``) for display.""" | ||
| value = float(nbytes) | ||
| for unit in ("B", "KiB", "MiB", "GiB", "TiB"): | ||
| if abs(value) < 1024.0: | ||
| return f"{value:.0f}B" if unit == "B" else f"{value:.1f}{unit}" | ||
| value /= 1024.0 | ||
| return f"{value:.1f}PiB" | ||
|
Comment on lines
+884
to
+891
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This probably isn't necessary. I don't think the size of the reservation should appear in the label... |
||
|
|
||
|
|
||
| @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) | ||
| class MemoryReservationRequest: | ||
| """ | ||
| A request to reserve memory, recorded on a Quent Task. | ||
|
|
||
| cudf-polars reserves (but does not necessarily allocate) device or host | ||
| memory before operations that grow their memory footprint. A reservation | ||
| is not always satisfiable right away, which gives the runtime a chance to | ||
| apply backpressure or spill rather than run out of memory. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| purpose | ||
| What the memory is reserved for (e.g. ``"scan"``). Distinguishes | ||
| reservations made by a single operator. | ||
|
Comment on lines
+906
to
+908
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd like to understand this better: what operators make multiple reservations? Requiring this might not be smart. Then we'll end up with redundant info in the telemetry like "The Scan operator reserved memory for a scan." And perhaps this could be an enum rather than an arbitrary string, to make things easier for consumers of the output. |
||
| size_bytes | ||
| The number of bytes requested. | ||
| mem_type | ||
| The memory tier reserved from (e.g. ``"DEVICE"``). | ||
| net_memory_delta | ||
| The expected lasting change in memory usage, which is smaller than | ||
| ``size_bytes`` for operations whose peak usage is transient. | ||
| allow_overbooking | ||
| Whether the runtime may hand out a reservation it cannot back, | ||
| or ``None`` to use the rapidsmpf default. | ||
| sequence_number | ||
| The sequence number of the chunk this reservation is for, if any. | ||
| granted | ||
| Whether the reservation was satisfied. A failed reservation still | ||
| took time, so it is worth recording. | ||
|
|
||
| Notes | ||
| ----- | ||
| Reservations are recorded as the ``Allocating`` state of a Quent | ||
| :class:`Task`, so the time spent in that state is the time it took to | ||
| satisfy the request. See | ||
| :meth:`~cudf_polars.quent._context.QuentContext._emit_memory_reservation_events`. | ||
|
|
||
| Quent's data processing domain doesn't declare attributes on the | ||
| ``Allocating`` state yet, so an analyzer built against the current model | ||
| reads the timing but ignores the attributes below. They're still written | ||
| to the event stream, and will show up once the model declares them. | ||
|
Comment on lines
+931
to
+935
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove this. |
||
| """ | ||
|
|
||
| purpose: str | ||
| size_bytes: int | ||
| mem_type: str | ||
| net_memory_delta: int | None = None | ||
| allow_overbooking: bool | None = None | ||
| sequence_number: int | None = None | ||
| granted: bool = True | ||
|
|
||
| @property | ||
| def label(self) -> str: | ||
| """A compact description, e.g. ``scan-256.0MiB-device``.""" | ||
| return ( | ||
| f"{self.purpose}-{_format_bytes(self.size_bytes)}-{self.mem_type.lower()}" | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove the size from the label. |
||
| ) | ||
|
|
||
| def to_dict(self) -> dict[str, Any]: | ||
| """Serialize to the flat attribute layout used by Quent FSM states.""" | ||
| attributes: dict[str, Any] = { | ||
| "purpose": self.purpose, | ||
| "size_bytes": self.size_bytes, | ||
| "mem_type": self.mem_type, | ||
| "granted": self.granted, | ||
| } | ||
| if self.net_memory_delta is not None: | ||
| attributes["net_memory_delta"] = self.net_memory_delta | ||
| if self.allow_overbooking is not None: | ||
| attributes["allow_overbooking"] = self.allow_overbooking | ||
| if self.sequence_number is not None: | ||
| attributes["sequence_number"] = self.sequence_number | ||
| return attributes | ||
|
|
||
|
|
||
| @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) | ||
| class Task: | ||
| """A Quent Task representing a unit of work on an operator.""" | ||
|
|
@@ -921,6 +1007,43 @@ def from_ir( | |
| operator_id=quent_ir_execution_context.quent_operator.id, | ||
| ) | ||
|
|
||
| @classmethod | ||
| def for_memory_reservation( | ||
| cls, | ||
| request: MemoryReservationRequest, | ||
| quent_ir_execution_context: QuentIRExecutionContext, | ||
| ) -> Self: | ||
| """ | ||
| Build an operator-scoped Quent Task recording a memory reservation. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| request | ||
| The reservation being made. Its :attr:`MemoryReservationRequest.label` | ||
| goes into the task's instance name, so the size and memory tier are | ||
| legible without reading the task's attributes. | ||
| quent_ir_execution_context | ||
| The Quent IR execution context, which is used to get the operator | ||
| the reservation is made on behalf of. | ||
|
|
||
| Returns | ||
| ------- | ||
| The operator-scoped Quent Task. | ||
| """ | ||
| operator = quent_ir_execution_context.quent_operator | ||
| # Reservations for a single chunk are already distinguished by their | ||
| # sequence number. Fall back to a token for the reservations that | ||
| # aren't per-chunk (e.g. gathering a whole collective's output). | ||
| suffix = ( | ||
| uuid.uuid4().hex[:8] | ||
| if request.sequence_number is None | ||
| else request.sequence_number | ||
| ) | ||
| return cls( | ||
| instance_name=f"reserve-{request.label}-{operator.id.hex[:8]}-{suffix}", | ||
| operator_id=operator.id, | ||
| ) | ||
|
|
||
| def queueing(self, timestamp: int | None = None) -> Event: | ||
| """Build a Quent Task Queueing event.""" | ||
| return Event( | ||
|
|
@@ -943,22 +1066,37 @@ def allocating( | |
| self, | ||
| resource_id: uuid.UUID, | ||
| timestamp: int | None = None, | ||
| *, | ||
| reservation: MemoryReservationRequest | None = None, | ||
| ) -> Event: | ||
| """Build a Quent Task Allocating event.""" | ||
| """ | ||
| Build a Quent Task Allocating event. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| resource_id | ||
| The Quent Processor (thread) doing the allocation. | ||
| timestamp | ||
| The event timestamp, defaulting to now. | ||
| reservation | ||
| The memory reservation being waited on, when this transition | ||
| represents a call into rapidsmpf's memory admission control. | ||
| """ | ||
| allocating_data: dict[str, Any] = { | ||
| "use_thread": { | ||
| "resource_id": str(resource_id), | ||
| "capacity": None, | ||
| } | ||
| } | ||
| if reservation is not None: | ||
| allocating_data.update(reservation.to_dict()) | ||
| return Event( | ||
| id=self.id, | ||
| timestamp=timestamp if timestamp is not None else time.time_ns(), | ||
| data={ | ||
| EventName.TASK.value: { | ||
| "seq": next(self._seq), | ||
| "state": { | ||
| "Allocating": { | ||
| "use_thread": { | ||
| "resource_id": str(resource_id), | ||
| "capacity": None, | ||
| } | ||
| } | ||
| }, | ||
| "state": {"Allocating": allocating_data}, | ||
| } | ||
| }, | ||
| ) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a note: making a memory reservation is async in python, so this will to be running on the asyncio event loop, concurrently with many other things. I think this is the right way to model this in Quent, but we'll want to double check that. We aren't really consuming CPU resources to run this task.