Files
2026-07-13 13:17:40 +08:00

463 lines
16 KiB
Cython

# cython: profile=False
# cython: boundscheck=False
# cython: wraparound=False
# cython: cdivision=True
# cython: initializedcheck=False
# =============================================================================
# WARNING: This file is used EXCLUSIVELY by Ray Serve.
# =============================================================================
#
# These Cython-optimized timeseries utilities exist solely to support the
# Ray Serve controller's autoscaling metrics pipeline (specifically the
# per-deployment timeseries aggregation in the Serve replica scheduler).
#
# This code lives in `ray/includes/` and is compiled into `_raylet.so` because
# Rather than introducing a new top-level `.pyx` / `.so` for a single
# Serve-internal optimization, we include it here alongside the other
# `.pxi` helpers that ship in `_raylet`.
#
# If you are not working on Ray Serve autoscaling, you almost certainly do not
# need to modify this file.
# =============================================================================
# C library imports
from libc.stdlib cimport malloc, free
from libc.math cimport round as c_round, isnan, nan, isinf
# Heap node for k-way merge
cdef struct _TsHeapNode:
double timestamp
int series_idx
double value
int position_in_series # Current position within the series
cdef inline void _ts_heap_sift_down(_TsHeapNode* heap, int size, int pos) noexcept nogil:
"""Sift down operation for min-heap (inline for performance)."""
cdef int smallest, left, right
cdef _TsHeapNode temp
while True:
smallest = pos
left = 2 * pos + 1
right = 2 * pos + 2
if left < size and heap[left].timestamp < heap[smallest].timestamp:
smallest = left
if right < size and heap[right].timestamp < heap[smallest].timestamp:
smallest = right
if smallest == pos:
break
# Swap
temp = heap[pos]
heap[pos] = heap[smallest]
heap[smallest] = temp
pos = smallest
cdef inline void _ts_heap_sift_up(_TsHeapNode* heap, int pos) noexcept nogil:
"""Sift up operation for min-heap (inline for performance)."""
cdef int parent
cdef _TsHeapNode temp
while pos > 0:
parent = (pos - 1) // 2
if heap[parent].timestamp <= heap[pos].timestamp:
break
# Swap
temp = heap[pos]
heap[pos] = heap[parent]
heap[parent] = temp
pos = parent
cdef inline void _ts_heap_pop(_TsHeapNode* heap, int* size) noexcept nogil:
"""Remove minimum element from heap."""
if size[0] <= 0:
return
heap[0] = heap[size[0] - 1]
size[0] -= 1
if size[0] > 0:
_ts_heap_sift_down(heap, size[0], 0)
cdef inline void _ts_heap_push(_TsHeapNode* heap, int* size, _TsHeapNode node) noexcept nogil:
"""Add element to heap."""
heap[size[0]] = node
_ts_heap_sift_up(heap, size[0])
size[0] += 1
cdef int _kway_merge_timeseries_nogil(double** timestamps_arrays, double** values_arrays,
int* series_lengths, int num_series,
int result_capacity,
double** out_timestamps, double** out_values) noexcept nogil:
"""
Fully nogil k-way merge operating on C arrays.
Assumptions:
- Each input series is sorted by timestamp in ascending order
- Values represent instantaneous gauge measurements (non-negative)
Args:
timestamps_arrays: Array of pointers to timestamp arrays for each series
values_arrays: Array of pointers to value arrays for each series
series_lengths: Array of lengths for each series
num_series: Number of series to merge
result_capacity: Pre-allocated capacity (should be >= sum of all series lengths)
out_timestamps: Output pointer for result timestamps
out_values: Output pointer for result values
Returns: Number of points in merged result, or -1 on error
"""
cdef:
int i, pos_in_series, series_idx
int heap_size = 0
double timestamp, value, old_value
double running_total = 0.0
double rounded_timestamp, last_rounded_timestamp = -1.0
_TsHeapNode new_node
int result_count = 0
# C arrays for performance
double* current_values = <double*>malloc(num_series * sizeof(double))
int* series_positions = <int*>malloc(num_series * sizeof(int))
_TsHeapNode* merge_heap = <_TsHeapNode*>malloc(num_series * sizeof(_TsHeapNode))
double* result_timestamps = <double*>malloc(result_capacity * sizeof(double))
double* result_values = <double*>malloc(result_capacity * sizeof(double))
if not current_values or not series_positions or not merge_heap or not result_timestamps or not result_values:
# Memory allocation failed
if current_values:
free(current_values)
if series_positions:
free(series_positions)
if merge_heap:
free(merge_heap)
if result_timestamps:
free(result_timestamps)
if result_values:
free(result_values)
return -1
# Initialize arrays
for i in range(num_series):
current_values[i] = 0.0
series_positions[i] = 0
# Push first element from each series to heap
if series_lengths[i] > 0:
merge_heap[heap_size].timestamp = timestamps_arrays[i][0]
merge_heap[heap_size].series_idx = i
merge_heap[heap_size].value = values_arrays[i][0]
merge_heap[heap_size].position_in_series = 0
heap_size += 1
# Build initial heap
for i in range(heap_size // 2 - 1, -1, -1):
_ts_heap_sift_down(merge_heap, heap_size, i)
# K-way merge
while heap_size > 0:
# Get minimum element
timestamp = merge_heap[0].timestamp
series_idx = merge_heap[0].series_idx
value = merge_heap[0].value
pos_in_series = merge_heap[0].position_in_series
# Update running total
old_value = current_values[series_idx]
current_values[series_idx] = value
running_total += value - old_value
# Remove from heap
_ts_heap_pop(merge_heap, &heap_size)
# Push next element from same series if available
series_positions[series_idx] = pos_in_series + 1
if series_positions[series_idx] < series_lengths[series_idx]:
new_node.timestamp = timestamps_arrays[series_idx][series_positions[series_idx]]
new_node.series_idx = series_idx
new_node.value = values_arrays[series_idx][series_positions[series_idx]]
new_node.position_in_series = series_positions[series_idx]
_ts_heap_push(merge_heap, &heap_size, new_node)
# Only add point if value changed
if value != old_value:
# Round to 10ms precision
rounded_timestamp = c_round(timestamp * 100.0) / 100.0
# Check if we can merge with last point
if result_count > 0 and last_rounded_timestamp == rounded_timestamp:
# Update last point's value
result_values[result_count - 1] = running_total
else:
# Add new point (capacity is pre-allocated to be large enough)
result_timestamps[result_count] = rounded_timestamp
result_values[result_count] = running_total
result_count += 1
last_rounded_timestamp = rounded_timestamp
# Clean up
free(current_values)
free(series_positions)
free(merge_heap)
# Return results
out_timestamps[0] = result_timestamps
out_values[0] = result_values
return result_count
def merge_instantaneous_total_cython(list replicas_timeseries):
"""
Cython-optimized k-way merge for timeseries.
This is a drop-in replacement for the Python version with 5-10x speedup.
Assumptions:
- Each input timeseries is sorted by timestamp in ascending order
- Values represent instantaneous gauge measurements
Args:
replicas_timeseries: List of timeseries. Each timeseries is a list of
objects with .timestamp and .value attributes.
Returns:
List of (timestamp, value) tuples representing the merged timeseries.
"""
# Filter empty series
cdef list active_series = [series for series in replicas_timeseries if series]
if not active_series:
return []
if len(active_series) == 1:
# Convert to tuples for consistent return type
return [(point.timestamp, point.value) for point in active_series[0]]
cdef:
int num_series = len(active_series)
int i, j
int total_points = 0
object point, series
bint alloc_failed = False
# C arrays for all timestamps and values
double** timestamps_arrays = <double**>malloc(num_series * sizeof(double*))
double** values_arrays = <double**>malloc(num_series * sizeof(double*))
int* series_lengths = <int*>malloc(num_series * sizeof(int))
double* result_timestamps = NULL
double* result_values = NULL
int result_count
if not timestamps_arrays or not values_arrays or not series_lengths:
# Memory allocation failed
if timestamps_arrays:
free(timestamps_arrays)
if values_arrays:
free(values_arrays)
if series_lengths:
free(series_lengths)
raise MemoryError("Failed to allocate memory for merge operation")
# Initialize pointers to NULL for safe cleanup
for i in range(num_series):
timestamps_arrays[i] = NULL
values_arrays[i] = NULL
try:
# Extract all data from Python objects into C arrays
for i in range(num_series):
series = active_series[i]
series_lengths[i] = len(series)
total_points += series_lengths[i]
timestamps_arrays[i] = <double*>malloc(series_lengths[i] * sizeof(double))
values_arrays[i] = <double*>malloc(series_lengths[i] * sizeof(double))
if not timestamps_arrays[i] or not values_arrays[i]:
alloc_failed = True
break
# Copy data from Python objects to C arrays
for j in range(series_lengths[i]):
point = series[j]
timestamps_arrays[i][j] = point.timestamp
values_arrays[i][j] = point.value
if alloc_failed:
raise MemoryError("Failed to allocate memory for series data")
# Perform merge with full nogil
# Pass total_points as capacity (worst case: all points output)
with nogil:
result_count = _kway_merge_timeseries_nogil(timestamps_arrays, values_arrays,
series_lengths, num_series,
total_points,
&result_timestamps, &result_values)
if result_count < 0:
# Note: _kway_merge_timeseries_nogil frees all memory on error
raise MemoryError("Failed during merge operation")
# Convert C arrays back to Python tuples
merged = [None] * result_count
for i in range(result_count):
merged[i] = (result_timestamps[i], result_values[i])
return merged
finally:
# Centralized cleanup: safe even if some allocations failed
# Free result arrays (allocated by _kway_merge_timeseries_nogil)
if result_timestamps:
free(result_timestamps)
if result_values:
free(result_values)
if timestamps_arrays:
for i in range(num_series):
if timestamps_arrays[i]:
free(timestamps_arrays[i])
free(timestamps_arrays)
if values_arrays:
for i in range(num_series):
if values_arrays[i]:
free(values_arrays[i])
free(values_arrays)
if series_lengths:
free(series_lengths)
cdef double _compute_time_weighted_average_nogil(double* timestamps, double* values, int n,
double window_start, double window_end) noexcept nogil:
"""
Fully nogil time-weighted average computation on C arrays.
Returns: Time-weighted average or NaN to indicate None (invalid result)
"""
cdef:
int i
double total_weighted_value = 0.0
double total_duration = 0.0
double current_value = 0.0
double current_time
double timestamp, value, duration
if window_end <= window_start:
return nan("")
current_time = window_start
# Find value at window_start (LOCF)
for i in range(n):
timestamp = timestamps[i]
if timestamp <= window_start:
current_value = values[i]
else:
break
# Process segments
for i in range(n):
timestamp = timestamps[i]
value = values[i]
if timestamp <= window_start:
continue
if timestamp >= window_end:
break
# Add contribution of current segment
# Note: timestamp < window_end is guaranteed here due to the break above
duration = timestamp - current_time
if duration > 0:
total_weighted_value += current_value * duration
total_duration += duration
current_value = value
current_time = timestamp
# Add final segment
if current_time < window_end:
duration = window_end - current_time
total_weighted_value += current_value * duration
total_duration += duration
if total_duration > 0:
return total_weighted_value / total_duration
return nan("")
def time_weighted_average_cython(list timeseries, double window_start,
double window_end, double last_window_s=1.0):
"""
Cython-optimized time-weighted average calculation.
Assumptions:
- Input timeseries is sorted by timestamp in ascending order
- Values are treated as a step function (LOCF - Last Observation Carried Forward)
Args:
timeseries: List of objects with .timestamp and .value attributes
window_start: Start of window (negative infinity means use first timestamp)
window_end: End of window (negative infinity means use last timestamp + last_window_s)
last_window_s: Window size for last segment
Returns:
Time-weighted average or None (returned as None when result would be invalid)
"""
if not timeseries:
return None
cdef:
int n = len(timeseries)
int i
double result
object point
double* timestamps = <double*>malloc(n * sizeof(double))
double* values = <double*>malloc(n * sizeof(double))
if not timestamps or not values:
if timestamps:
free(timestamps)
if values:
free(values)
raise MemoryError("Failed to allocate memory for time weighted average")
try:
# Extract data from Python objects into C arrays
for i in range(n):
point = timeseries[i]
timestamps[i] = point.timestamp
values[i] = point.value
# Handle window boundaries
# Use negative infinity as sentinel for None (any valid float including -1.0 works)
if isinf(window_start) and window_start < 0:
window_start = timestamps[0]
if isinf(window_end) and window_end < 0:
window_end = timestamps[n - 1] + last_window_s
# Compute with full nogil
with nogil:
result = _compute_time_weighted_average_nogil(timestamps, values, n,
window_start, window_end)
return None if isnan(result) else result
finally:
free(timestamps)
free(values)