rlx_ir/async_copy.rs
1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Async tile-copy + double-buffer primitives (plan #22).
6//!
7//! Borrowed from MAX's
8//! `layout/{tma_async, tensor_core_async}.mojo` +
9//! `structured_kernels/{pipeline, pipeline_storage, barriers}.mojo`.
10//! On NVIDIA the equivalent is TMA (Tensor Memory Accelerator);
11//! on Apple Silicon there's no direct analog because the GPU and
12//! CPU share a unified memory pool — but the *pipelining* idea
13//! still pays off: while shader N runs on tile N, you issue an
14//! async copy / blit for tile N+1 and let the two overlap.
15//!
16//! The shape this module exposes:
17//!
18//! - [`DoubleBuffer<T>`] — owns two `T` instances with a `swap`
19//! pointer; `current()` is what compute reads, `next_mut()` is
20//! where the async copy lands.
21//! - [`AsyncCopy`] trait — `issue()` schedules a copy and returns
22//! a [`BarrierToken`]; `wait()` blocks until the matching
23//! issue has completed.
24//! - [`SyncCopy`] — the CPU implementation: every issue is a
25//! memcpy + a fresh token; `wait()` is a no-op (the copy
26//! already completed). Sufficient for unit tests and for
27//! bench harnesses that run the pipeline pattern with no
28//! real overlap.
29//!
30//! A future Metal impl plugs in via the same trait. The Metal
31//! version would issue a `MTLBlitCommandEncoder.copy(...)` on a
32//! distinct command queue and signal an `MTLEvent` for `wait()`.
33
34use std::sync::atomic::{AtomicU64, Ordering};
35
36/// Opaque ticket returned by [`AsyncCopy::issue`]. Pass back to
37/// [`AsyncCopy::wait`] to block until the corresponding copy is
38/// done. Tokens are scoped to one engine — don't pass them across.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct BarrierToken(pub u64);
41
42/// Pluggable async-copy engine. Backends (`SyncCopy` for CPU,
43/// future `MetalBlitCopy` for GPU) implement this.
44pub trait AsyncCopy {
45 /// Schedule a `bytes`-byte copy from `src` to `dst`. Returns a
46 /// token usable with [`Self::wait`].
47 /// # Safety
48 /// `src` valid for read, `dst` valid for write, `bytes` doesn't
49 /// overflow either region. Caller ensures `src` and `dst` don't
50 /// alias unless that's intentional.
51 unsafe fn issue(&mut self, src: *const u8, dst: *mut u8, bytes: usize) -> BarrierToken;
52
53 /// Block until the copy referred to by `token` has completed.
54 fn wait(&mut self, token: BarrierToken);
55}
56
57/// CPU "async" copy — actually synchronous. `issue()` does a
58/// `memcpy` immediately and returns a fresh token; `wait()` is a
59/// no-op. Useful as the test fixture and for code paths that
60/// don't actually need overlap.
61pub struct SyncCopy {
62 counter: AtomicU64,
63}
64
65impl SyncCopy {
66 pub const fn new() -> Self {
67 Self {
68 counter: AtomicU64::new(0),
69 }
70 }
71}
72
73impl Default for SyncCopy {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl AsyncCopy for SyncCopy {
80 unsafe fn issue(&mut self, src: *const u8, dst: *mut u8, bytes: usize) -> BarrierToken {
81 unsafe {
82 std::ptr::copy_nonoverlapping(src, dst, bytes);
83 }
84 BarrierToken(self.counter.fetch_add(1, Ordering::Relaxed))
85 }
86
87 fn wait(&mut self, _token: BarrierToken) {
88 // Sync copy: already done at issue() time.
89 }
90}
91
92/// Two-buffer ring. `current()` is what compute reads this step;
93/// `next_mut()` is where the *next* async copy should land. Call
94/// `swap()` after waiting on the current copy to advance.
95#[derive(Debug, Clone)]
96pub struct DoubleBuffer<T> {
97 buffers: [T; 2],
98 active: usize,
99}
100
101impl<T> DoubleBuffer<T> {
102 pub fn new(a: T, b: T) -> Self {
103 Self {
104 buffers: [a, b],
105 active: 0,
106 }
107 }
108
109 pub fn current(&self) -> &T {
110 &self.buffers[self.active]
111 }
112 pub fn current_mut(&mut self) -> &mut T {
113 &mut self.buffers[self.active]
114 }
115
116 pub fn next(&self) -> &T {
117 &self.buffers[1 - self.active]
118 }
119 pub fn next_mut(&mut self) -> &mut T {
120 &mut self.buffers[1 - self.active]
121 }
122
123 /// Flip which buffer is current. Typical pattern:
124 /// ```text
125 /// // At step k:
126 /// engine.wait(prev_token); // copy of tile-k done
127 /// let token_for_kp1 = engine.issue(src_kp1, double.next_mut(), bytes);
128 /// compute(double.current()); // shader runs on tile-k
129 /// double.swap(); // tile-(k+1) becomes current
130 /// // → at step k+1, wait(token_for_kp1) etc.
131 /// ```
132 pub fn swap(&mut self) {
133 self.active = 1 - self.active;
134 }
135
136 /// Both buffers' shared length, when `T = Vec<u8>` / `Vec<f32>`.
137 /// Exposed for symmetry; many callers don't need it.
138 pub fn pair(&self) -> (&T, &T) {
139 (&self.buffers[0], &self.buffers[1])
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn double_buffer_swap_round_trip() {
149 let mut db = DoubleBuffer::new(vec![1u8; 4], vec![2u8; 4]);
150 assert_eq!(db.current(), &vec![1u8; 4]);
151 db.swap();
152 assert_eq!(db.current(), &vec![2u8; 4]);
153 db.swap();
154 assert_eq!(db.current(), &vec![1u8; 4]);
155 }
156
157 #[test]
158 fn sync_copy_round_trips_data() {
159 let src = [1u8, 2, 3, 4];
160 let mut dst = [0u8; 4];
161 let mut engine = SyncCopy::new();
162 let token = unsafe { engine.issue(src.as_ptr(), dst.as_mut_ptr(), 4) };
163 engine.wait(token);
164 assert_eq!(dst, src);
165 }
166
167 #[test]
168 fn pipelined_pattern_through_double_buffer() {
169 // Simulate the canonical compute-overlap-copy loop:
170 // tile 0..N comes in two halves [0..2] and [2..N]; the
171 // compute step is "sum of the buffer".
172 let source: Vec<u8> = (0..16u8).collect();
173 let tile_bytes = 4;
174 let mut db = DoubleBuffer::new(vec![0u8; tile_bytes], vec![0u8; tile_bytes]);
175 let mut engine = SyncCopy::new();
176
177 // Prime: load tile 0 into the *current* slot.
178 let t0 =
179 unsafe { engine.issue(source.as_ptr(), db.current_mut().as_mut_ptr(), tile_bytes) };
180 engine.wait(t0);
181
182 let mut total: u64 = 0;
183 let mut tile_idx = 1usize;
184 while tile_idx * tile_bytes < source.len() {
185 // Issue copy for next tile into the inactive slot.
186 let t = unsafe {
187 engine.issue(
188 source.as_ptr().add(tile_idx * tile_bytes),
189 db.next_mut().as_mut_ptr(),
190 tile_bytes,
191 )
192 };
193 // Compute on the current tile.
194 total += db.current().iter().map(|&b| b as u64).sum::<u64>();
195 // Step boundary.
196 engine.wait(t);
197 db.swap();
198 tile_idx += 1;
199 }
200 // Drain the last tile.
201 total += db.current().iter().map(|&b| b as u64).sum::<u64>();
202
203 // Sum of 0..16 = 120.
204 let expected: u64 = (0..16u64).sum();
205 assert_eq!(total, expected);
206 }
207}