Skip to content

Commit e5ca0a9

Browse files
mariusaemeta-codesync[bot]
authored andcommitted
implement Nagle's algorithm for port reducers (#1405)
Summary: Pull Request resolved: #1405 This implements a timed flush for port reducers. It introduces a new struct, `ReducerOpts`, which specifies the interval used; otherwise a new global configuration variable (default 50ms) is used. This will enable timely streaming updates through cast/accumulate. In the near future we should strive to do even better: for value meshes specifically, we know the number of responses expected. We can introduce an 'n-shot' port which keeps track of the number of updates expected at each split. This will also allow us to implement proper lifecycle management of these ports. ghstack-source-id: 313903298 Reviewed By: pzhan9 Differential Revision: D83768514 fbshipit-source-id: a6f712adc156e88f548b0ff09a3f1bfe5e7a7b31
1 parent e22d937 commit e5ca0a9

File tree

8 files changed

+285
-52
lines changed

8 files changed

+285
-52
lines changed

hyperactor/src/accum.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111
use std::collections::HashMap;
1212
use std::marker::PhantomData;
1313
use std::sync::OnceLock;
14+
use std::time::Duration;
1415

1516
use serde::Deserialize;
1617
use serde::Serialize;
1718
use serde::de::DeserializeOwned;
1819

1920
use crate as hyperactor; // for macros
2021
use crate::Named;
22+
use crate::config;
2123
use crate::data::Serialized;
2224
use crate::reference::Index;
2325

@@ -45,6 +47,21 @@ pub struct ReducerSpec {
4547
pub builder_params: Option<Serialized>,
4648
}
4749

50+
/// Runtime behavior of reducers.
51+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named, Default)]
52+
pub struct ReducerOpts {
53+
/// The maximum interval between updates. When unspecified, a default
54+
/// interval is used.
55+
pub max_update_interval: Option<Duration>,
56+
}
57+
58+
impl ReducerOpts {
59+
pub(crate) fn max_update_interval(&self) -> Duration {
60+
self.max_update_interval
61+
.unwrap_or(config::global::get(config::SPLIT_MAX_BUFFER_AGE))
62+
}
63+
}
64+
4865
/// Commutative reducer for an accumulator. This is used to coallesce updates.
4966
/// For example, if the accumulator is a sum, its reducer calculates and returns
5067
/// the sum of 2 updates. This is helpful in split ports, where a large number

hyperactor/src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ declare_attrs! {
6363
@meta(CONFIG_ENV_VAR = "HYPERACTOR_SPLIT_MAX_BUFFER_SIZE".to_string())
6464
pub attr SPLIT_MAX_BUFFER_SIZE: usize = 5;
6565

66+
/// The maximum time an update can be buffered before being reduced.
67+
@meta(CONFIG_ENV_VAR = "HYPERACTOR_SPLIT_MAX_BUFFER_AGE".to_string())
68+
pub attr SPLIT_MAX_BUFFER_AGE: Duration = Duration::from_millis(50);
69+
6670
/// Timeout used by proc mesh for stopping an actor.
6771
@meta(CONFIG_ENV_VAR = "HYPERACTOR_STOP_ACTOR_TIMEOUT".to_string())
6872
pub attr STOP_ACTOR_TIMEOUT: Duration = Duration::from_secs(1);

hyperactor/src/context.rs

Lines changed: 126 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
//! Context traits are sealed, and thus can only be implemented by data types in the
1515
//! core hyperactor crate.
1616
17+
use std::mem::take;
18+
use std::sync::Arc;
1719
use std::sync::Mutex;
1820
use std::sync::OnceLock;
1921

@@ -24,12 +26,16 @@ use crate::ActorId;
2426
use crate::Instance;
2527
use crate::PortId;
2628
use crate::accum;
29+
use crate::accum::ErasedCommReducer;
30+
use crate::accum::ReducerOpts;
2731
use crate::accum::ReducerSpec;
2832
use crate::attrs::Attrs;
33+
use crate::config;
2934
use crate::data::Serialized;
3035
use crate::mailbox;
3136
use crate::mailbox::MailboxSender;
3237
use crate::mailbox::MessageEnvelope;
38+
use crate::time::Alarm;
3339

3440
/// A mailbox context provides a mailbox.
3541
pub trait Mailbox: crate::private::Sealed + Send + Sync {
@@ -58,7 +64,12 @@ pub(crate) trait MailboxExt: Mailbox {
5864
fn post(&self, dest: PortId, headers: Attrs, data: Serialized);
5965

6066
/// Split a port, using a provided reducer spec, if provided.
61-
fn split(&self, port_id: PortId, reducer_spec: Option<ReducerSpec>) -> anyhow::Result<PortId>;
67+
fn split(
68+
&self,
69+
port_id: PortId,
70+
reducer_spec: Option<ReducerSpec>,
71+
reducer_opts: Option<ReducerOpts>,
72+
) -> anyhow::Result<PortId>;
6273
}
6374

6475
// Tracks mailboxes that have emitted a `CanSend::post` warning due to
@@ -90,7 +101,12 @@ impl<T: Mailbox + Send + Sync> MailboxExt for T {
90101
MailboxSender::post(self.mailbox(), envelope, return_handle);
91102
}
92103

93-
fn split(&self, port_id: PortId, reducer_spec: Option<ReducerSpec>) -> anyhow::Result<PortId> {
104+
fn split(
105+
&self,
106+
port_id: PortId,
107+
reducer_spec: Option<ReducerSpec>,
108+
reducer_opts: Option<ReducerOpts>,
109+
) -> anyhow::Result<PortId> {
94110
fn post(mailbox: &mailbox::Mailbox, port_id: PortId, msg: Serialized) {
95111
mailbox::MailboxSender::post(
96112
mailbox,
@@ -122,24 +138,70 @@ impl<T: Mailbox + Send + Sync> MailboxExt for T {
122138
post(&mailbox, port_id.clone(), serialized);
123139
Ok(())
124140
}),
125-
Some(r) => {
126-
let buffer = Mutex::new(mailbox::SplitPortBuffer::default());
127-
Box::new(move |serialized: Serialized| {
141+
Some(reducer) => {
142+
let buffer: Arc<Mutex<UpdateBuffer>> =
143+
Arc::new(Mutex::new(UpdateBuffer::new(reducer)));
144+
145+
let alarm = Alarm::new();
146+
147+
{
148+
let mut sleeper = alarm.sleeper();
149+
let buffer = Arc::clone(&buffer);
150+
let port_id = port_id.clone();
151+
let mailbox = mailbox.clone();
152+
tokio::spawn(async move {
153+
while sleeper.sleep().await {
154+
let mut buf = buffer.lock().unwrap();
155+
match buf.reduce() {
156+
None => (),
157+
Some(Ok(reduced)) => post(&mailbox, port_id.clone(), reduced),
158+
// We simply ignore errors here, and let them be propagated
159+
// later in the enqueueing function.
160+
//
161+
// If this is the last update, then this strategy will cause a hang.
162+
// We should obtain a supervisor here from our send context and notify
163+
// it.
164+
Some(Err(e)) => tracing::error!(
165+
"error while reducing update: {}; waiting until the next send to propagate",
166+
e
167+
),
168+
}
169+
}
170+
});
171+
}
172+
173+
// Note: alarm is held in the closure while the port is active;
174+
// when it is dropped, the alarm terminates, and so does the sleeper
175+
// task.
176+
let alarm = Mutex::new(alarm);
177+
178+
// Default to global configuration if not specified.
179+
let reducer_opts = reducer_opts.unwrap_or_default();
180+
181+
Box::new(move |update: Serialized| {
128182
// Hold the lock until messages are sent. This is to avoid another
129183
// invocation of this method trying to send message concurrently and
130184
// cause messages delivered out of order.
185+
//
186+
// We also always acquire alarm *after* the buffer, to avoid deadlocks.
131187
let mut buf = buffer.lock().unwrap();
132-
if let Some(buffered) = buf.push(serialized) {
133-
let reduced = r.reduce_updates(buffered).map_err(|(e, mut b)| {
134-
(
135-
b.pop()
136-
.expect("there should be at least one update from buffer"),
137-
e,
138-
)
139-
})?;
140-
post(&mailbox, port_id.clone(), reduced);
188+
let was_empty = buf.is_empty();
189+
match buf.push(update) {
190+
None if was_empty => {
191+
alarm
192+
.lock()
193+
.unwrap()
194+
.arm(reducer_opts.max_update_interval());
195+
Ok(())
196+
}
197+
None => Ok(()),
198+
Some(Ok(reduced)) => {
199+
alarm.lock().unwrap().disarm();
200+
post(&mailbox, port_id.clone(), reduced);
201+
Ok(())
202+
}
203+
Some(Err(e)) => Err((buf.pop().unwrap(), e)),
141204
}
142-
Ok(())
143205
})
144206
}
145207
};
@@ -153,3 +215,52 @@ impl<T: Mailbox + Send + Sync> MailboxExt for T {
153215
Ok(split_port)
154216
}
155217
}
218+
219+
struct UpdateBuffer {
220+
buffered: Vec<Serialized>,
221+
reducer: Box<dyn ErasedCommReducer + Send + Sync + 'static>,
222+
}
223+
224+
impl UpdateBuffer {
225+
fn new(reducer: Box<dyn ErasedCommReducer + Send + Sync + 'static>) -> Self {
226+
Self {
227+
buffered: Vec::new(),
228+
reducer,
229+
}
230+
}
231+
232+
fn is_empty(&self) -> bool {
233+
self.buffered.is_empty()
234+
}
235+
236+
fn pop(&mut self) -> Option<Serialized> {
237+
self.buffered.pop()
238+
}
239+
240+
/// Push a new item to the buffer, and optionally return any items that should
241+
/// be flushed.
242+
fn push(&mut self, serialized: Serialized) -> Option<anyhow::Result<Serialized>> {
243+
let limit = config::global::get(config::SPLIT_MAX_BUFFER_SIZE);
244+
245+
self.buffered.push(serialized);
246+
if self.buffered.len() >= limit {
247+
self.reduce()
248+
} else {
249+
None
250+
}
251+
}
252+
253+
fn reduce(&mut self) -> Option<anyhow::Result<Serialized>> {
254+
if self.buffered.is_empty() {
255+
None
256+
} else {
257+
match self.reducer.reduce_updates(take(&mut self.buffered)) {
258+
Ok(reduced) => Some(Ok(reduced)),
259+
Err((e, b)) => {
260+
self.buffered = b;
261+
Some(Err(e))
262+
}
263+
}
264+
}
265+
}
266+
}

0 commit comments

Comments
 (0)