use std::{collections::VecDeque, sync::Arc, thread, time::Duration};
use anyhow::{Result, anyhow};
use burn::{
module::{AutodiffModule, Module},
optim::Optimizer,
record::{BinBytesRecorder, FullPrecisionSettings, Recorder},
tensor::{
Int, Tensor, TensorData,
backend::{AutodiffBackend, Backend},
},
};
use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, TryRecvError, unbounded};
use rand::{SeedableRng, rngs::StdRng};
use super::{stats::TrainingStats, trainer::PPOTrainerBurn};
use crate::{
buffer::rollout::{RolloutBatch, RolloutBuffer, compute_advantages},
env::Environment,
multi_agent::{AgentId, ControlMessage, Experience, PolicyBroadcast},
};
const LEARNER_RECV_TIMEOUT: Duration = Duration::from_secs(60);
const ACTOR_BROADCAST_WAIT: Duration = Duration::from_millis(50);
#[derive(Debug, Clone)]
pub struct AsyncActorLearnerConfig {
pub num_actors: usize,
pub num_steps: usize,
pub total_env_steps: usize,
pub broadcast_every: usize,
pub max_lead_steps: usize,
pub gamma: f32,
pub gae_lambda: f32,
pub use_vtrace: bool,
pub vtrace_rho_bar: f32,
pub vtrace_c_bar: f32,
pub seed: u64,
}
impl Default for AsyncActorLearnerConfig {
fn default() -> Self {
Self {
num_actors: 4,
num_steps: 256,
total_env_steps: 200_000,
broadcast_every: 1,
max_lead_steps: 0,
gamma: 0.99,
gae_lambda: 0.95,
use_vtrace: false,
vtrace_rho_bar: 1.0,
vtrace_c_bar: 1.0,
seed: 0,
}
}
}
impl AsyncActorLearnerConfig {
pub fn validate(&self) -> Result<()> {
if self.num_actors == 0 {
return Err(anyhow!("num_actors must be >= 1"));
}
if self.num_steps == 0 {
return Err(anyhow!("num_steps must be >= 1"));
}
if self.broadcast_every == 0 {
return Err(anyhow!("broadcast_every must be >= 1"));
}
if self.total_env_steps < self.num_steps * self.num_actors {
return Err(anyhow!(
"total_env_steps ({}) must cover at least one update ({} = num_steps * num_actors)",
self.total_env_steps,
self.num_steps * self.num_actors
));
}
if !(0.0..=1.0).contains(&self.gamma) || self.gamma == 0.0 {
return Err(anyhow!("gamma must be in (0, 1]"));
}
if !(0.0..=1.0).contains(&self.gae_lambda) || self.gae_lambda == 0.0 {
return Err(anyhow!("gae_lambda must be in (0, 1]"));
}
if self.vtrace_rho_bar <= 0.0 {
return Err(anyhow!("vtrace_rho_bar must be positive, got {}", self.vtrace_rho_bar));
}
if self.vtrace_c_bar <= 0.0 {
return Err(anyhow!("vtrace_c_bar must be positive, got {}", self.vtrace_c_bar));
}
if self.max_lead_steps != 0 && self.max_lead_steps < self.broadcast_every * self.num_steps {
return Err(anyhow!(
"max_lead_steps ({}) must be >= broadcast_every * num_steps ({}) \
or the learner's fill loop deadlocks",
self.max_lead_steps,
self.broadcast_every * self.num_steps
));
}
Ok(())
}
pub fn num_updates(&self) -> usize {
(self.total_env_steps / (self.num_steps * self.num_actors)).max(1)
}
pub fn effective_max_lead_steps(&self) -> usize {
if self.max_lead_steps != 0 {
self.max_lead_steps
} else {
2 * self.broadcast_every * self.num_steps
}
}
pub fn actor_throttle(&self) -> ActorThrottle {
ActorThrottle {
steps_per_broadcast: self.broadcast_every * self.num_steps,
max_lead_steps: self.effective_max_lead_steps(),
}
}
pub fn use_vtrace(mut self, enabled: bool) -> Self {
self.use_vtrace = enabled;
self
}
pub fn vtrace_rho_bar(mut self, rho_bar: f32) -> Self {
self.vtrace_rho_bar = rho_bar;
self
}
pub fn vtrace_c_bar(mut self, c_bar: f32) -> Self {
self.vtrace_c_bar = c_bar;
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct ActorThrottle {
pub steps_per_broadcast: usize,
pub max_lead_steps: usize,
}
impl ActorThrottle {
pub fn disabled() -> Self {
Self { steps_per_broadcast: 0, max_lead_steps: 0 }
}
pub fn should_pause(&self, steps_sent: usize, version: u64) -> bool {
self.max_lead_steps != 0
&& steps_sent >= (version as usize) * self.steps_per_broadcast + self.max_lead_steps
}
}
pub struct ActorChannels {
pub experience_tx: Sender<Experience>,
pub broadcast_rx: Receiver<PolicyBroadcast>,
pub control_rx: Receiver<ControlMessage>,
}
#[derive(Debug, Clone, Default)]
pub struct ActorStats {
pub actor_id: AgentId,
pub steps_sent: usize,
pub episodes_completed: usize,
pub policy_updates_received: usize,
pub last_policy_version: u64,
}
pub struct ActorHandle {
pub actor_id: AgentId,
join: thread::JoinHandle<Result<ActorStats>>,
broadcast_tx: Sender<PolicyBroadcast>,
control_tx: Sender<ControlMessage>,
}
impl ActorHandle {
pub fn send_broadcast(&self, broadcast: PolicyBroadcast) -> bool {
self.broadcast_tx.send(broadcast).is_ok()
}
pub fn send_shutdown(&self) {
let _ = self.control_tx.send(ControlMessage::Shutdown);
}
pub fn join(self) -> Result<ActorStats> {
self.join.join().map_err(|_| anyhow!("actor thread panicked"))?
}
}
pub fn load_policy_from_broadcast<B2, M>(
module: M,
broadcast: &PolicyBroadcast,
device: &B2::Device,
) -> Result<M>
where
B2: Backend,
M: Module<B2>,
{
match broadcast {
PolicyBroadcast::Bytes { bytes, version } => {
let recorder = BinBytesRecorder::<FullPrecisionSettings>::default();
let record =
<BinBytesRecorder<FullPrecisionSettings> as Recorder<B2>>::load::<M::Record>(
&recorder,
bytes.as_ref().clone(),
device,
)
.map_err(|e| anyhow!("failed to load policy broadcast v{version}: {e}"))?;
Ok(module.load_record(record))
}
}
}
pub fn serialize_policy<B, P>(policy: &P) -> Result<Vec<u8>>
where
B: AutodiffBackend,
P: AutodiffModule<B>,
{
let record = policy.valid().into_record();
let recorder = BinBytesRecorder::<FullPrecisionSettings>::default();
<BinBytesRecorder<FullPrecisionSettings> as Recorder<B::InnerBackend>>::record(
&recorder,
record,
(),
)
.map_err(|e| anyhow!("failed to serialize policy: {e}"))
}
#[allow(clippy::too_many_arguments)] pub fn actor_thread<B2, M, E, F>(
actor_id: AgentId,
mut env: E,
mut policy: M,
channels: ActorChannels,
device: B2::Device,
seed: u64,
throttle: ActorThrottle,
mut act_fn: F,
) -> Result<ActorStats>
where
B2: Backend,
M: Module<B2>,
E: Environment<Action = i64>,
F: FnMut(&M, &[f32], &mut StdRng) -> (i64, f32, f32),
{
let mut rng = StdRng::seed_from_u64(seed);
let mut stats = ActorStats { actor_id, ..Default::default() };
env.reset();
loop {
match channels.control_rx.try_recv() {
Ok(ControlMessage::Shutdown) | Err(TryRecvError::Disconnected) => break,
Ok(_) | Err(TryRecvError::Empty) => {}
}
let mut latest: Option<PolicyBroadcast> = None;
while let Ok(broadcast) = channels.broadcast_rx.try_recv() {
latest = Some(broadcast);
}
if latest.is_none() && throttle.should_pause(stats.steps_sent, stats.last_policy_version) {
match channels.broadcast_rx.recv_timeout(ACTOR_BROADCAST_WAIT) {
Ok(broadcast) => latest = Some(broadcast),
Err(RecvTimeoutError::Timeout) => continue, Err(RecvTimeoutError::Disconnected) => break,
}
}
if let Some(broadcast) = latest {
let version = broadcast.version();
policy = load_policy_from_broadcast(policy, &broadcast, &device)?;
stats.policy_updates_received += 1;
stats.last_policy_version = version;
tracing::debug!(actor_id, version, "actor loaded policy broadcast");
}
let observation = env.get_observation();
let (action, log_prob, value) = act_fn(&policy, &observation, &mut rng);
let result = env.step(action);
let done = result.terminated || result.truncated;
let experience = Experience::new(
actor_id,
observation,
vec![action],
result.reward,
result.observation,
result.terminated,
result.truncated,
value,
log_prob,
);
if channels.experience_tx.send(experience).is_err() {
break; }
stats.steps_sent += 1;
if done {
stats.episodes_completed += 1;
env.reset();
}
}
Ok(stats)
}
#[allow(clippy::too_many_arguments)] pub fn spawn_actor<B2, M, E, F>(
actor_id: AgentId,
env: E,
policy: M,
experience_tx: Sender<Experience>,
device: B2::Device,
seed: u64,
throttle: ActorThrottle,
act_fn: F,
) -> ActorHandle
where
B2: Backend,
B2::Device: Send + 'static,
M: Module<B2> + Send + 'static,
E: Environment<Action = i64> + Send + 'static,
F: FnMut(&M, &[f32], &mut StdRng) -> (i64, f32, f32) + Send + 'static,
{
let (broadcast_tx, broadcast_rx) = unbounded();
let (control_tx, control_rx) = unbounded();
let channels = ActorChannels { experience_tx, broadcast_rx, control_rx };
let join = thread::Builder::new()
.name(format!("thrust-actor-{actor_id}"))
.spawn(move || {
actor_thread::<B2, M, E, F>(
actor_id, env, policy, channels, device, seed, throttle, act_fn,
)
})
.expect("failed to spawn actor thread");
ActorHandle { actor_id, join, broadcast_tx, control_tx }
}
#[derive(Debug, Clone, Default)]
pub struct LearnerReport {
pub updates_completed: usize,
pub env_steps_consumed: usize,
pub episodes_completed: usize,
pub broadcasts_sent: usize,
pub last_policy_version: u64,
pub episode_rewards: Vec<f32>,
pub final_stats: Option<TrainingStats>,
}
impl LearnerReport {
pub fn mean_recent_episode_reward(&self, n: usize) -> f32 {
if self.episode_rewards.is_empty() {
return 0.0;
}
let start = self.episode_rewards.len().saturating_sub(n);
let recent = &self.episode_rewards[start..];
recent.iter().sum::<f32>() / recent.len() as f32
}
}
#[allow(clippy::too_many_arguments)] pub fn learner_loop<B, P, O, F, G>(
config: &AsyncActorLearnerConfig,
mut trainer: PPOTrainerBurn<B, P, O>,
obs_dim: usize,
device: &B::Device,
experience_rx: &Receiver<Experience>,
actors: &[ActorHandle],
mut evaluate_fn: F,
mut value_fn: G,
) -> Result<(PPOTrainerBurn<B, P, O>, LearnerReport)>
where
B: AutodiffBackend,
P: AutodiffModule<B> + Clone,
O: Optimizer<P, B>,
F: FnMut(&P, Tensor<B, 2>, Tensor<B, 1, Int>) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>),
G: FnMut(&P, Tensor<B, 2>) -> Vec<f32>,
{
config.validate()?;
let num_actors = config.num_actors;
let num_steps = config.num_steps;
let num_updates = config.num_updates();
let mut report = LearnerReport::default();
let mut pending: Vec<VecDeque<Experience>> = vec![VecDeque::new(); num_actors];
let mut running_reward = vec![0.0_f32; num_actors];
let mut version: u64 = 0;
for update in 0..num_updates {
while pending.iter().any(|q| q.len() < num_steps) {
let experience = experience_rx.recv_timeout(LEARNER_RECV_TIMEOUT).map_err(|e| {
anyhow!(
"learner starved waiting for experiences ({e}); \
actor death is fatal in Phase 2 (see module docs)"
)
})?;
let actor = experience.agent_id;
if actor >= num_actors {
return Err(anyhow!(
"experience agent_id {actor} out of range (num_actors = {num_actors})"
));
}
pending[actor].push_back(experience);
}
let mut buffer = RolloutBuffer::new(num_steps, num_actors, obs_dim);
let mut last_next_obs = vec![0.0_f32; num_actors * obs_dim];
for actor in 0..num_actors {
for step in 0..num_steps {
let exp = pending[actor].pop_front().expect("fill loop guarantees num_steps");
debug_assert_eq!(exp.agent_id, actor);
buffer.add(
step,
actor,
&exp.observation,
exp.action[0],
exp.reward,
exp.value,
exp.log_prob,
exp.terminated,
exp.truncated,
);
running_reward[actor] += exp.reward;
if exp.is_done() {
report.episode_rewards.push(running_reward[actor]);
report.episodes_completed += 1;
running_reward[actor] = 0.0;
trainer.increment_episodes(1);
}
if step == num_steps - 1 {
last_next_obs[actor * obs_dim..(actor + 1) * obs_dim]
.copy_from_slice(&exp.next_observation);
}
}
}
report.env_steps_consumed += num_steps * num_actors;
let last_obs_t = Tensor::<B, 2>::from_data(
TensorData::new(last_next_obs, [num_actors, obs_dim]),
device,
);
let last_values = value_fn(trainer.policy(), last_obs_t);
if config.use_vtrace {
let batch = RolloutBatch::from_buffer(&buffer);
let flat_len = num_steps * num_actors;
let obs_t = Tensor::<B, 2>::from_data(
TensorData::new(batch.observations.clone(), [flat_len, obs_dim]),
device,
);
let actions_t = Tensor::<B, 1, Int>::from_data(
TensorData::new(batch.actions.clone(), [flat_len]),
device,
);
let (flat_lps, _, _) = evaluate_fn(trainer.policy(), obs_t, actions_t);
let flat_target: Vec<f32> = flat_lps.into_data().to_vec().map_err(|e| {
anyhow!("failed to read V-trace target log-probs off the device: {e:?}")
})?;
let target_log_probs: Vec<Vec<f32>> = (0..num_steps)
.map(|step| flat_target[step * num_actors..(step + 1) * num_actors].to_vec())
.collect();
buffer.compute_vtrace_advantages(
&target_log_probs,
&last_values,
config.gamma,
config.vtrace_rho_bar,
config.vtrace_c_bar,
);
} else {
compute_advantages(&mut buffer, &last_values, config.gamma, config.gae_lambda);
}
let tensors = RolloutBatch::from_buffer(&buffer).to_burn_tensors::<B>(device);
let stats = trainer.train_step(
tensors.observations,
tensors.actions,
tensors.old_log_probs,
tensors.old_values,
tensors.advantages,
tensors.returns,
&mut evaluate_fn,
)?;
report.updates_completed += 1;
if (update + 1) % config.broadcast_every == 0 {
version += 1;
let bytes = Arc::new(serialize_policy(trainer.policy())?);
let mut delivered = 0usize;
for handle in actors {
if handle
.send_broadcast(PolicyBroadcast::Bytes { version, bytes: Arc::clone(&bytes) })
{
delivered += 1;
}
}
report.broadcasts_sent += 1;
report.last_policy_version = version;
tracing::info!(
version,
delivered,
num_actors,
"learner broadcast policy version {version} to {delivered}/{num_actors} actors"
);
}
tracing::info!(
"update {:>3}/{} env_steps={:>7} episodes={:>4} mean_ep_reward(last≤100)={:6.1} policy_loss={:7.4} entropy={:5.3}",
update + 1,
num_updates,
report.env_steps_consumed,
report.episodes_completed,
report.mean_recent_episode_reward(100),
stats.policy_loss,
stats.entropy,
);
report.final_stats = Some(stats);
}
for handle in actors {
handle.send_shutdown();
}
Ok((trainer, report))
}
#[cfg(test)]
mod tests {
use burn::{
backend::{Autodiff, NdArray},
optim::AdamConfig,
};
use super::*;
use crate::{
env::{SpaceInfo, SpaceType, StepInfo, StepResult},
policy::mlp::{MlpBurnConfig, MlpBurnPolicy},
train::{optimizer::BurnOptimizer, ppo::PPOConfig},
};
type B = Autodiff<NdArray<f32>>;
type Inner = NdArray<f32>;
const OBS_DIM: usize = 2;
const ACTION_DIM: usize = 2;
struct StubEnv {
t: usize,
}
impl Environment for StubEnv {
type Action = i64;
type State = usize;
fn reset(&mut self) {
self.t = 0;
}
fn get_observation(&self) -> Vec<f32> {
vec![self.t as f32; OBS_DIM]
}
fn step(&mut self, _action: i64) -> StepResult {
self.t += 1;
StepResult {
observation: self.get_observation(),
reward: 1.0,
terminated: self.t.is_multiple_of(5),
truncated: false,
info: StepInfo::default(),
}
}
fn observation_space(&self) -> SpaceInfo {
SpaceInfo { shape: vec![OBS_DIM], space_type: SpaceType::Box }
}
fn action_space(&self) -> SpaceInfo {
SpaceInfo { shape: vec![1], space_type: SpaceType::Discrete(ACTION_DIM) }
}
fn render(&self) -> Vec<u8> {
Vec::new()
}
fn close(&mut self) {}
fn clone_state(&self) -> usize {
self.t
}
fn restore_state(&mut self, state: &usize) {
self.t = *state;
}
}
fn seeded_inner_policy(seed: u64) -> MlpBurnPolicy<Inner> {
let device = Default::default();
MlpBurnPolicy::<Inner>::with_config(
OBS_DIM,
ACTION_DIM,
MlpBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(seed),
&device,
)
}
fn seeded_autodiff_policy(seed: u64) -> MlpBurnPolicy<B> {
let device = Default::default();
MlpBurnPolicy::<B>::with_config(
OBS_DIM,
ACTION_DIM,
MlpBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(seed),
&device,
)
}
fn act_fn(policy: &MlpBurnPolicy<Inner>, obs: &[f32], rng: &mut StdRng) -> (i64, f32, f32) {
let device = Default::default();
let t =
Tensor::<Inner, 2>::from_data(TensorData::new(obs.to_vec(), [1, obs.len()]), &device);
let (actions, log_probs, values) = policy.get_action_host_seeded(t, rng);
(actions[0], log_probs[0], values[0])
}
#[test]
fn config_default_is_valid_and_derives_updates() {
let config = AsyncActorLearnerConfig::default();
config.validate().unwrap();
assert_eq!(config.broadcast_every, 1);
assert_eq!(config.num_updates(), 200_000 / (256 * 4));
assert_eq!(config.effective_max_lead_steps(), 2 * 256);
assert!(!config.use_vtrace);
assert_eq!(config.vtrace_rho_bar, 1.0);
assert_eq!(config.vtrace_c_bar, 1.0);
let throttle = config.actor_throttle();
assert_eq!(throttle.steps_per_broadcast, 256);
assert_eq!(throttle.max_lead_steps, 512);
assert!(!throttle.should_pause(511, 0));
assert!(throttle.should_pause(512, 0));
assert!(!throttle.should_pause(512, 1)); assert!(throttle.should_pause(768, 1));
assert!(!ActorThrottle::disabled().should_pause(usize::MAX - 1, 0));
}
#[test]
fn config_rejects_bad_values() {
let base = AsyncActorLearnerConfig::default();
assert!(AsyncActorLearnerConfig { num_actors: 0, ..base.clone() }.validate().is_err());
assert!(AsyncActorLearnerConfig { num_steps: 0, ..base.clone() }.validate().is_err());
assert!(
AsyncActorLearnerConfig { broadcast_every: 0, ..base.clone() }
.validate()
.is_err()
);
assert!(
AsyncActorLearnerConfig { total_env_steps: 10, ..base.clone() }
.validate()
.is_err()
);
assert!(AsyncActorLearnerConfig { gamma: 0.0, ..base.clone() }.validate().is_err());
assert!(AsyncActorLearnerConfig { gae_lambda: 1.5, ..base.clone() }.validate().is_err());
assert!(AsyncActorLearnerConfig { max_lead_steps: 255, ..base }.validate().is_err());
}
#[test]
fn config_vtrace_fields_validate() {
let base = AsyncActorLearnerConfig::default();
assert!(AsyncActorLearnerConfig { use_vtrace: true, ..base.clone() }.validate().is_ok());
assert!(
AsyncActorLearnerConfig { vtrace_rho_bar: 0.0, ..base.clone() }
.validate()
.is_err()
);
assert!(
AsyncActorLearnerConfig { vtrace_rho_bar: -1.0, ..base.clone() }
.validate()
.is_err()
);
assert!(
AsyncActorLearnerConfig { vtrace_c_bar: 0.0, ..base.clone() }
.validate()
.is_err()
);
assert!(
AsyncActorLearnerConfig { vtrace_c_bar: -1.0, ..base.clone() }
.validate()
.is_err()
);
let cfg = base.use_vtrace(true).vtrace_rho_bar(0.9).vtrace_c_bar(1.1);
assert!(cfg.use_vtrace);
assert_eq!(cfg.vtrace_rho_bar, 0.9);
assert_eq!(cfg.vtrace_c_bar, 1.1);
cfg.validate().unwrap();
}
#[test]
fn policy_broadcast_bytes_roundtrip() {
let device = Default::default();
let source = seeded_autodiff_policy(42);
let target = seeded_inner_policy(43);
let bytes = serialize_policy(&source).unwrap();
let broadcast = PolicyBroadcast::Bytes { version: 1, bytes: Arc::new(bytes) };
let loaded = load_policy_from_broadcast(target, &broadcast, &device).unwrap();
let obs =
Tensor::<Inner, 2>::from_data(TensorData::new(vec![0.1, -0.2], [1, OBS_DIM]), &device);
let (logits_src, value_src) = source.valid().forward(obs.clone());
let (logits_loaded, value_loaded) = loaded.forward(obs);
let src: Vec<f32> = logits_src.into_data().to_vec().unwrap();
let got: Vec<f32> = logits_loaded.into_data().to_vec().unwrap();
assert_eq!(src, got, "loaded policy must reproduce source logits bit-for-bit");
let v_src: Vec<f32> = value_src.into_data().to_vec().unwrap();
let v_got: Vec<f32> = value_loaded.into_data().to_vec().unwrap();
assert_eq!(v_src, v_got);
}
#[test]
fn actor_thread_sends_ordered_experiences_and_loads_broadcasts() {
const PAUSE_AT: usize = 40;
let device = burn::backend::ndarray::NdArrayDevice::default();
let (experience_tx, experience_rx) = unbounded();
let handle = spawn_actor::<Inner, _, _, _>(
3,
StubEnv { t: 0 },
seeded_inner_policy(7),
experience_tx,
device,
123,
ActorThrottle { steps_per_broadcast: PAUSE_AT, max_lead_steps: PAUSE_AT },
act_fn,
);
let mut received = Vec::new();
for _ in 0..PAUSE_AT {
let exp = experience_rx.recv_timeout(Duration::from_secs(30)).unwrap();
assert_eq!(exp.agent_id, 3);
received.push(exp);
}
for (i, exp) in received.iter().enumerate() {
let expected_t = (i % 5) as f32;
assert_eq!(exp.observation, vec![expected_t; OBS_DIM], "step {i} out of order");
assert_eq!(exp.action.len(), 1);
assert_eq!(exp.terminated, (i + 1) % 5 == 0);
}
let source = seeded_autodiff_policy(99);
let bytes = Arc::new(serialize_policy(&source).unwrap());
assert!(handle.send_broadcast(PolicyBroadcast::Bytes { version: 7, bytes }));
experience_rx
.recv_timeout(Duration::from_secs(30))
.expect("actor should resume after loading the broadcast");
handle.send_shutdown();
let stats = handle.join().unwrap();
assert_eq!(stats.actor_id, 3);
assert!(stats.steps_sent > PAUSE_AT);
assert!(stats.episodes_completed >= PAUSE_AT / 5);
assert!(
stats.policy_updates_received >= 1,
"actor must have loaded the broadcast (got {})",
stats.policy_updates_received
);
assert_eq!(stats.last_policy_version, 7);
}
#[test]
fn learner_loop_runs_updates_and_broadcasts() {
let device = Default::default();
let num_actors = 2;
let num_steps = 8;
let config = AsyncActorLearnerConfig {
num_actors,
num_steps,
total_env_steps: num_steps * num_actors * 3, broadcast_every: 1,
max_lead_steps: 2 * num_steps,
gamma: 0.99,
gae_lambda: 0.95,
use_vtrace: false,
vtrace_rho_bar: 1.0,
vtrace_c_bar: 1.0,
seed: 0,
};
let policy = seeded_autodiff_policy(0);
let inner_opt = AdamConfig::new().init();
let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
let ppo_config = PPOConfig::default().batch_size(8).n_epochs(1).target_kl(1.0);
let trainer = PPOTrainerBurn::new(ppo_config, policy, burn_opt).unwrap();
let (experience_tx, experience_rx) = unbounded();
let actors: Vec<ActorHandle> = (0..num_actors)
.map(|i| {
spawn_actor::<Inner, _, _, _>(
i,
StubEnv { t: 0 },
trainer.policy().valid(),
experience_tx.clone(),
device,
100 + i as u64,
config.actor_throttle(),
act_fn,
)
})
.collect();
drop(experience_tx);
let (trainer, report) = learner_loop(
&config,
trainer,
OBS_DIM,
&device,
&experience_rx,
&actors,
|p: &MlpBurnPolicy<B>, o, a| p.evaluate_actions(o, a),
|p: &MlpBurnPolicy<B>, o| p.forward(o).1.into_data().to_vec().unwrap(),
)
.unwrap();
assert_eq!(report.updates_completed, 3);
assert_eq!(report.env_steps_consumed, num_steps * num_actors * 3);
assert_eq!(report.broadcasts_sent, 3);
assert_eq!(report.last_policy_version, 3);
assert!(report.episodes_completed >= num_actors);
assert_eq!(trainer.total_episodes(), report.episodes_completed);
let stats = report.final_stats.expect("3 updates ran");
assert!(stats.policy_loss.is_finite());
assert!(stats.value_loss.is_finite());
for handle in actors {
let stats = handle.join().unwrap();
assert!(
stats.policy_updates_received >= 1,
"actor {} never received a policy broadcast",
stats.actor_id
);
assert!(stats.last_policy_version >= 1);
}
}
fn run_one_update(use_vtrace: bool) -> TrainingStats {
let device = Default::default();
let num_actors = 1;
let num_steps = 8;
let config = AsyncActorLearnerConfig {
num_actors,
num_steps,
total_env_steps: num_steps * num_actors, broadcast_every: 1,
max_lead_steps: 2 * num_steps,
gamma: 0.99,
gae_lambda: 1.0, use_vtrace,
vtrace_rho_bar: 1.0,
vtrace_c_bar: 1.0,
seed: 0,
};
let policy = seeded_autodiff_policy(0);
let inner_opt = AdamConfig::new().init();
let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
let ppo_config = PPOConfig::default()
.batch_size(num_steps * num_actors)
.n_epochs(1)
.target_kl(1.0);
let trainer = PPOTrainerBurn::new(ppo_config, policy, burn_opt).unwrap();
let (experience_tx, experience_rx) = unbounded();
let actors: Vec<ActorHandle> = (0..num_actors)
.map(|i| {
spawn_actor::<Inner, _, _, _>(
i,
StubEnv { t: 0 },
trainer.policy().valid(),
experience_tx.clone(),
device,
100 + i as u64,
config.actor_throttle(),
act_fn,
)
})
.collect();
drop(experience_tx);
let (_trainer, report) = learner_loop(
&config,
trainer,
OBS_DIM,
&device,
&experience_rx,
&actors,
|p: &MlpBurnPolicy<B>, o, a| p.evaluate_actions(o, a),
|p: &MlpBurnPolicy<B>, o| p.forward(o).1.into_data().to_vec().unwrap(),
)
.unwrap();
for handle in actors {
let _ = handle.join();
}
report.final_stats.expect("one update ran")
}
#[test]
fn learner_loop_vtrace_on_policy_matches_gae() {
let gae = run_one_update(false);
let vtrace = run_one_update(true);
eprintln!(
"on-policy: gae(policy={:.9}, value={:.9}) vtrace(policy={:.9}, value={:.9})",
gae.policy_loss, gae.value_loss, vtrace.policy_loss, vtrace.value_loss
);
let rel = |a: f64, b: f64| (a - b).abs() / a.abs().max(b.abs()).max(1e-6);
assert!(
rel(gae.value_loss, vtrace.value_loss) < 1e-2,
"value_loss should match on-policy: gae={} vtrace={}",
gae.value_loss,
vtrace.value_loss
);
assert!(
(gae.policy_loss - vtrace.policy_loss).abs() < 1e-3,
"policy_loss should match on-policy: gae={} vtrace={}",
gae.policy_loss,
vtrace.policy_loss
);
}
#[test]
fn learner_loop_vtrace_stale_completes() {
let device = Default::default();
let num_actors = 2;
let num_steps = 8;
let config = AsyncActorLearnerConfig {
num_actors,
num_steps,
total_env_steps: num_steps * num_actors * 5, broadcast_every: 3, max_lead_steps: 6 * num_steps, gamma: 0.99,
gae_lambda: 0.95,
use_vtrace: true,
vtrace_rho_bar: 1.0,
vtrace_c_bar: 1.0,
seed: 0,
};
let policy = seeded_autodiff_policy(0);
let inner_opt = AdamConfig::new().init();
let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
let ppo_config = PPOConfig::default().batch_size(8).n_epochs(1).target_kl(1.0);
let trainer = PPOTrainerBurn::new(ppo_config, policy, burn_opt).unwrap();
let (experience_tx, experience_rx) = unbounded();
let actors: Vec<ActorHandle> = (0..num_actors)
.map(|i| {
spawn_actor::<Inner, _, _, _>(
i,
StubEnv { t: 0 },
trainer.policy().valid(),
experience_tx.clone(),
device,
200 + i as u64,
config.actor_throttle(),
act_fn,
)
})
.collect();
drop(experience_tx);
let (_trainer, report) = learner_loop(
&config,
trainer,
OBS_DIM,
&device,
&experience_rx,
&actors,
|p: &MlpBurnPolicy<B>, o, a| p.evaluate_actions(o, a),
|p: &MlpBurnPolicy<B>, o| p.forward(o).1.into_data().to_vec().unwrap(),
)
.unwrap();
for handle in actors {
let _ = handle.join();
}
assert_eq!(report.updates_completed, 5);
let stats = report.final_stats.expect("5 updates ran");
assert!(stats.policy_loss.is_finite(), "policy_loss must be finite under staleness");
assert!(stats.value_loss.is_finite(), "value_loss must be finite under staleness");
assert!(stats.entropy.is_finite(), "entropy must be finite under staleness");
for r in &report.episode_rewards {
assert!(r.is_finite(), "episode reward must be finite");
}
}
#[test]
fn vtrace_target_log_probs_differ_from_behavior_when_policy_updated() {
let device = Default::default();
let behavior = seeded_autodiff_policy(0);
let updated = seeded_autodiff_policy(7);
let obs = vec![0.0_f32, 0.0, 1.0, 1.0];
let obs_t = Tensor::<B, 2>::from_data(TensorData::new(obs, [2, OBS_DIM]), &device);
let actions_t =
Tensor::<B, 1, Int>::from_data(TensorData::new(vec![0_i64, 1], [2]), &device);
let (behavior_lp, _, _) = behavior.evaluate_actions(obs_t.clone(), actions_t.clone());
let (updated_lp, _, _) = updated.evaluate_actions(obs_t, actions_t);
let b: Vec<f32> = behavior_lp.into_data().to_vec().unwrap();
let u: Vec<f32> = updated_lp.into_data().to_vec().unwrap();
assert!(
b.iter().zip(&u).any(|(x, y)| (x - y).abs() > 1e-3),
"target log-probs under an updated policy must differ from behavior log-probs: \
{b:?} vs {u:?}"
);
}
}