Skip to main content

tcontract_exec/
workspace.rs

1//! Owning workspace: page-aligned, grow-only, reused per worker.
2//!
3//! A workspace belongs to exactly one owner — a `tcontract_exec::Pool` or a caller
4//! that wants reuse — and is *lent* to the driver for one call at a time. There
5//! is deliberately no process-global arena: storage handed to a team is
6//! returned to the provider that issued it, and a second owner can never see
7//! it.
8//!
9//! Two payload shapes:
10//!
11//! * **worker buffers** — the packed A block, the accumulator tile and the
12//!   scratch vector of one thread. They are allocated by the thread that will
13//!   write them, which is what makes their first touch local to the node that
14//!   keeps using them; a thread-local handle names its slot in the owner, and a
15//!   handle outlives nothing: the storage is the owner's, the handle is only an
16//!   index, and a slot that has been trimmed is simply re-created.
17//! * **team sets** — the shared packed B panel, the team's scatter vectors and
18//!   its barriers. They are taken exclusively for the duration of a call and
19//!   returned on drop, so two concurrent executes can never share one.
20//!
21//! # Exclusivity
22//!
23//! A slot that is *in the owner's map* is touched only while the map lock is
24//! held. A slot that is *checked out* is not in the map at all: the thread that
25//! checked it out is its only owner until it is returned, so a re-entrant call
26//! on that thread finds nothing and allocates fresh call-local buffers instead
27//! of aliasing the ones its outer call is using (waiting for itself would
28//! deadlock). `trim` and the accounting snapshot therefore never observe a slot
29//! another thread is writing.
30//!
31//! `ArenaProvider::traced()` additionally records every growth's thread and
32//! size, which is how the tests prove that a worker's buffers were allocated by
33//! the worker. Tracing is off by default and only covers buffers the owner
34//! keeps; the fresh re-entrant path is not traced.
35
36use std::collections::HashMap;
37use std::sync::atomic::{AtomicU64, Ordering};
38use std::sync::{Barrier, Mutex};
39
40/// The 4096-byte alignment every buffer in the workspace has.
41const PAGE: usize = 4096;
42
43/// Bytes and counts one execute needs. Buffer sizes are bytes because the
44/// provider is element-type erased: the driver knows the element type and
45/// converts its real counts once.
46///
47/// A zero entry never allocates.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub struct WorkspaceReq {
50    /// This thread's packed A block.
51    pub a_bytes: usize,
52    /// This thread's accumulator tile, including any induced-method scratch.
53    pub tile_bytes: usize,
54    /// Elements reserved for this thread's scatter scratch.
55    pub worker_scatter: usize,
56    /// The team's shared packed B panel.
57    pub b_bytes: usize,
58    /// Elements reserved for the team's block-scatter vectors.
59    pub team_scatter: usize,
60    /// Barriers the team needs, or zero when nothing is shared.
61    pub barriers: usize,
62}
63
64/// What an owner currently holds and currently lends out.
65///
66/// Exactness: both values are settled at every checkout, return, team take,
67/// lease drop and `trim`, so `ArenaProvider::stats` is exact whenever no
68/// execution is in flight. While one is, a checked-out slot is counted at the
69/// capacity it had when it was checked out, so `retained_bytes` is an unsettled
70/// estimate that can lag growth or shrinkage until the call returns.
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
72pub struct WorkspaceStats {
73    /// Capacity the owner holds: worker A/tile/scatter and team
74    /// panel/scatter/barriers, idle or in use.
75    pub retained_bytes: usize,
76    /// The part of `retained_bytes` lent to a call right now.
77    pub leased_bytes: usize,
78}
79
80/// Page-aligned, grow-only, never zeroed storage.
81///
82/// The caller writes before reading, which is the contract the packing and
83/// kernel code already has; zeroing would only double the traffic.
84#[derive(Default, Debug)]
85struct PageBuf {
86    ptr: *mut u8,
87    cap: usize,
88    /// Owner key this buffer reports growths under, or zero when untraced.
89    traced: u64,
90}
91
92// SAFETY: the buffer owns its allocation and hands out pointers only through
93// `&mut self` or an explicit, contracted call.
94unsafe impl Send for PageBuf {}
95
96impl PageBuf {
97    /// A buffer that reports its growths under `owner`, for tests that prove
98    /// where storage was first touched. Zero means untraced.
99    fn traced(owner: u64) -> Self {
100        Self {
101            traced: owner,
102            ..Self::default()
103        }
104    }
105
106    /// A pointer to at least `bytes` writable, page-aligned bytes, growing the
107    /// allocation if needed and reusing it otherwise. `ensure(0)` returns a
108    /// dangling but well-aligned pointer and allocates nothing.
109    fn ensure(&mut self, bytes: usize) -> *mut u8 {
110        if bytes == 0 {
111            return std::ptr::NonNull::<u8>::dangling().as_ptr();
112        }
113        if bytes > self.cap {
114            // Grow by doubling, so a sequence of slightly larger calls does not
115            // reallocate every time.
116            let want = bytes.next_power_of_two();
117            let layout = std::alloc::Layout::from_size_align(want, PAGE).expect("workspace layout");
118            // SAFETY: `want` is nonzero, and any old block was allocated with
119            // this same alignment and a power-of-two size.
120            let new = unsafe {
121                match self.ptr.is_null() {
122                    true => std::alloc::alloc(layout),
123                    false => {
124                        let old = std::alloc::Layout::from_size_align_unchecked(self.cap, PAGE);
125                        std::alloc::realloc(self.ptr, old, want)
126                    }
127                }
128            };
129            assert!(
130                !new.is_null(),
131                "workspace allocation of {want} bytes failed"
132            );
133            self.ptr = new;
134            self.cap = want;
135            if self.traced != 0 {
136                record(self.traced, want);
137            }
138        }
139        self.ptr
140    }
141
142    /// Pointer to the current allocation, or a dangling pointer when empty.
143    fn as_ptr(&self) -> *mut u8 {
144        if self.ptr.is_null() {
145            std::ptr::NonNull::<u8>::dangling().as_ptr()
146        } else {
147            self.ptr
148        }
149    }
150
151    /// Bytes currently allocated.
152    fn cap_bytes(&self) -> usize {
153        self.cap
154    }
155
156    /// Release the allocation. Callers must hold the buffer exclusively.
157    fn trim(&mut self) {
158        if !self.ptr.is_null() {
159            // SAFETY: allocated with this alignment and a power-of-two size.
160            unsafe {
161                std::alloc::dealloc(
162                    self.ptr,
163                    std::alloc::Layout::from_size_align_unchecked(self.cap, PAGE),
164                )
165            };
166            self.ptr = std::ptr::null_mut();
167            self.cap = 0;
168        }
169    }
170}
171
172impl Drop for PageBuf {
173    fn drop(&mut self) {
174        self.trim();
175    }
176}
177
178/// One thread's buffers, as the owner keeps them between calls.
179#[derive(Debug)]
180struct WorkerSlot {
181    a: PageBuf,
182    tile: PageBuf,
183    scratch: Vec<i64>,
184}
185
186impl WorkerSlot {
187    /// `owner` is the tracing key, or zero for an untraced owner.
188    fn new(owner: u64) -> Self {
189        Self {
190            a: PageBuf::traced(owner),
191            tile: PageBuf::traced(owner),
192            scratch: Vec::new(),
193        }
194    }
195
196    fn bytes(&self) -> usize {
197        self.a.cap_bytes()
198            + self.tile.cap_bytes()
199            + self.scratch.capacity() * core::mem::size_of::<i64>()
200    }
201
202    fn trim(&mut self) {
203        self.a.trim();
204        self.tile.trim();
205        self.scratch = Vec::new();
206    }
207}
208
209/// A team's shared storage: one packed B panel, the scatter vectors and the
210/// barriers that publish the panel between its threads.
211#[derive(Debug, Default)]
212struct TeamSet {
213    /// The packed B panel.
214    b: PageBuf,
215    /// Block-scatter vectors for this call, capacity-reused.
216    scatter: Vec<i64>,
217    /// One barrier per column group when `pm > 1`, else empty.
218    barriers: Vec<Barrier>,
219    pm: usize,
220    pn: usize,
221}
222
223impl TeamSet {
224    fn prepare(&mut self, req: &WorkspaceReq, pm: usize, pn: usize) {
225        self.scatter.clear();
226        self.scatter.reserve(req.team_scatter);
227        if (self.pm, self.pn) != (pm, pn) {
228            // A barrier cannot be reset, so a differently shaped team needs new
229            // ones; that is why the free list keeps sets and rebuilds here.
230            self.barriers = (0..req.barriers).map(|_| Barrier::new(pm)).collect();
231            self.pm = pm;
232            self.pn = pn;
233        }
234    }
235
236    fn bytes(&self) -> usize {
237        self.b.cap_bytes()
238            + self.scatter.capacity() * core::mem::size_of::<i64>()
239            + self.barriers.capacity() * core::mem::size_of::<Barrier>()
240    }
241}
242
243/// An exclusive loan of a team set, returned to its owner on drop.
244///
245/// The set is deliberately opaque: the panel grows only through
246/// [`TeamLease::panel`], so every byte the owner hands out is accounted.
247#[derive(Debug)]
248pub struct TeamLease<'a> {
249    owner: &'a ArenaProvider,
250    set: Option<TeamSet>,
251    /// Bytes this lease has added to the owner's leased total.
252    billed: usize,
253}
254
255impl<'a> TeamLease<'a> {
256    /// Size the shared panel and return its pointer, accounting the growth to
257    /// the owner. The driver sizes it because only the driver knows the element
258    /// type; the provider keeps the storage.
259    pub fn panel(&mut self, bytes: usize) -> *mut u8 {
260        let set = self.set.as_mut().expect("team lease holds its set");
261        let before = set.b.cap_bytes();
262        let ptr = set.b.ensure(bytes);
263        let after = set.b.cap_bytes();
264        if after != before {
265            let delta = after as isize - before as isize;
266            let mut st = lock(&self.owner.state);
267            st.owned = add_signed(st.owned, delta, "retained bytes");
268            st.leased = add_signed(st.leased, delta, "leased bytes");
269            st.panel = add_signed(st.panel, delta, "panel bytes");
270            self.billed = add_signed(self.billed, delta, "billed bytes");
271        }
272        ptr
273    }
274
275    /// The team's block-scatter buffer, sized by the driver each call.
276    pub fn scatter_mut(&mut self) -> &mut Vec<i64> {
277        &mut self.set.as_mut().expect("team lease holds its set").scatter
278    }
279
280    /// The team's block-scatter buffer.
281    pub fn scatter(&self) -> &[i64] {
282        &self.set.as_ref().expect("team lease holds its set").scatter
283    }
284
285    /// One barrier per column group, empty when nothing is shared.
286    pub fn barriers(&self) -> &[Barrier] {
287        &self
288            .set
289            .as_ref()
290            .expect("team lease holds its set")
291            .barriers
292    }
293}
294
295impl Drop for TeamLease<'_> {
296    fn drop(&mut self) {
297        if let Some(set) = self.set.take() {
298            let mut st = lock(&self.owner.state);
299            // The scatter vector may have been resized through `scatter_mut`
300            // without going through `panel`, so settle both directions.
301            let delta = set.bytes() as isize - self.billed as isize;
302            st.owned = add_signed(st.owned, delta, "retained bytes");
303            st.leased = sub(st.leased, self.billed, "leased bytes");
304            st.teams.push(set);
305        }
306    }
307}
308
309mod sealed {
310    /// Only the crate's own owner implements [`WorkspaceProvider`](super::WorkspaceProvider):
311    /// the driver writes through the raw pointers it hands out, so an
312    /// implementation must honour the arena's buffer and exclusivity contracts.
313    pub trait Sealed {}
314    impl Sealed for super::ArenaProvider {}
315}
316
317/// Storage an execution may borrow. Implemented by the owner; the driver only
318/// ever sees this trait, so nothing here depends on the driver.
319///
320/// Sealed: [`ArenaProvider`] is the supported way to lend reusable storage, and
321/// the trait is deliberately not implementable outside this crate.
322pub trait WorkspaceProvider: sealed::Sealed + Sync {
323    /// Run `f` with this thread's A block, tile and scatter scratch. A
324    /// re-entrant call on the same thread gets fresh buffers instead of the
325    /// ones its outer call is using.
326    fn with_worker(&self, req: &WorkspaceReq, f: &mut dyn FnMut(*mut u8, *mut u8, &mut Vec<i64>));
327
328    /// Take the team set for `pm x pn`, exclusively, until the lease drops.
329    fn take_team(&self, req: &WorkspaceReq, pm: usize, pn: usize) -> TeamLease<'_>;
330
331    /// Release idle storage. Live leases and checked-out worker slots are kept.
332    fn trim(&self);
333}
334
335/// What one owner holds and lends. One lock covers the storage maps and the
336/// counters, so a snapshot and an update share one synchronization boundary.
337#[derive(Debug, Default)]
338struct State {
339    workers: HashMap<u64, WorkerSlot>,
340    teams: Vec<TeamSet>,
341    /// Capacity of everything owned, idle or in use.
342    owned: usize,
343    /// The part of `owned` lent to a call right now.
344    leased: usize,
345    /// Capacity of the team panels alone, idle or leased. Independent of which
346    /// workers happened to take part, so a test can compare two runs of one
347    /// shape.
348    panel: usize,
349}
350
351/// A workspace owner: per-thread worker slots plus a free list of team sets.
352///
353/// One per owner — never process-global — so a lease always returns to the
354/// provider that issued it and no second owner can borrow it.
355#[derive(Debug)]
356pub struct ArenaProvider {
357    /// Process-unique and never recycled, so a thread-local handle can never be
358    /// confused with another provider that happens to share an address.
359    key: u64,
360    state: Mutex<State>,
361    next_worker: AtomicU64,
362    traced: u64,
363}
364
365impl Default for ArenaProvider {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371thread_local! {
372    /// `(provider key, worker slot)` pairs this thread has used. Thread-local
373    /// by construction, so a slot is never handed to a different thread after
374    /// its owner exits: the handle dies with the thread.
375    static HANDLES: std::cell::RefCell<Vec<(u64, u64)>> =
376        const { std::cell::RefCell::new(Vec::new()) };
377}
378
379static NEXT_PROVIDER: AtomicU64 = AtomicU64::new(1);
380static TRACE: Mutex<Vec<(u64, std::thread::ThreadId, usize)>> = Mutex::new(Vec::new());
381
382fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
383    m.lock().unwrap_or_else(|e| e.into_inner())
384}
385
386/// `value + delta`, rejecting a decrement below zero. Every caller establishes
387/// the bound; this turns a mistake into a loud failure instead of a wrap.
388fn add_signed(value: usize, delta: isize, what: &str) -> usize {
389    value
390        .checked_add_signed(delta)
391        .unwrap_or_else(|| panic!("workspace {what} went out of range"))
392}
393
394/// `value - amount`, rejecting an underflow for the same reason.
395fn sub(value: usize, amount: usize, what: &str) -> usize {
396    value
397        .checked_sub(amount)
398        .unwrap_or_else(|| panic!("workspace {what} went out of range"))
399}
400
401fn record(owner: u64, bytes: usize) {
402    lock(&TRACE).push((owner, std::thread::current().id(), bytes));
403}
404
405impl ArenaProvider {
406    /// A fresh owner with no storage.
407    pub fn new() -> Self {
408        Self {
409            key: NEXT_PROVIDER.fetch_add(1, Ordering::Relaxed),
410            state: Mutex::new(State::default()),
411            next_worker: AtomicU64::new(0),
412            traced: 0,
413        }
414    }
415
416    /// A fresh owner that records where its storage is first touched, readable
417    /// through [`ArenaProvider::trace_take`].
418    pub fn traced() -> Self {
419        let mut arena = Self::new();
420        arena.traced = arena.key;
421        arena
422    }
423
424    /// Take this owner's recorded buffer growths, leaving its log empty.
425    ///
426    /// Empty unless the owner was built with [`ArenaProvider::traced`].
427    pub fn trace_take(&self) -> Vec<(std::thread::ThreadId, usize)> {
428        let mut mine = Vec::new();
429        lock(&TRACE).retain(|(owner, thread, bytes)| {
430            if *owner == self.key {
431                mine.push((*thread, *bytes));
432                false
433            } else {
434                true
435            }
436        });
437        mine
438    }
439
440    /// Bytes currently retained and leased, for accounting by the host.
441    pub fn stats(&self) -> WorkspaceStats {
442        let st = lock(&self.state);
443        WorkspaceStats {
444            retained_bytes: st.owned,
445            leased_bytes: st.leased,
446        }
447    }
448
449    /// Bytes currently retained, for accounting in tests and diagnostics.
450    pub fn retained_bytes(&self) -> usize {
451        self.stats().retained_bytes
452    }
453
454    /// Panel bytes retained, idle or leased. Unlike
455    /// [`ArenaProvider::retained_bytes`] this does not depend on which workers
456    /// happened to take part, so it is what a test can compare between two runs
457    /// of one shape.
458    pub fn retained_panel_bytes(&self) -> usize {
459        lock(&self.state).panel
460    }
461
462    /// Check this thread's slot out of the map, or `None` when the thread
463    /// already holds it (a re-entrant call).
464    fn checkout(&self) -> Option<(u64, WorkerSlot, usize)> {
465        let known = HANDLES.with(|h| {
466            h.borrow()
467                .iter()
468                .find(|(k, _)| *k == self.key)
469                .map(|(_, i)| *i)
470        });
471        match known {
472            // The handle exists; a missing slot means an outer call holds it.
473            Some(index) => {
474                let mut st = lock(&self.state);
475                let slot = st.workers.remove(&index)?;
476                let billed = slot.bytes();
477                st.leased += billed;
478                Some((index, slot, billed))
479            }
480            None => {
481                let index = self.next_worker.fetch_add(1, Ordering::Relaxed);
482                HANDLES.with(|h| h.borrow_mut().push((self.key, index)));
483                // A fresh slot owns nothing yet, so the counters do not move.
484                Some((index, WorkerSlot::new(self.traced), 0))
485            }
486        }
487    }
488
489    /// Return a checked-out slot, settling both accounting directions.
490    fn give_back(&self, index: u64, slot: WorkerSlot, billed: usize) {
491        let delta = slot.bytes() as isize - billed as isize;
492        let mut st = lock(&self.state);
493        // `owned` follows the slot's capacity; `leased` gives back exactly what
494        // checkout added, since growth was never lent to anyone else.
495        st.owned = add_signed(st.owned, delta, "retained bytes");
496        st.leased = sub(st.leased, billed, "leased bytes");
497        st.workers.insert(index, slot);
498    }
499}
500
501/// Returns a checked-out slot even when the callback unwinds.
502struct SlotGuard<'a> {
503    owner: &'a ArenaProvider,
504    index: u64,
505    slot: Option<WorkerSlot>,
506    billed: usize,
507}
508
509impl Drop for SlotGuard<'_> {
510    fn drop(&mut self) {
511        if let Some(slot) = self.slot.take() {
512            self.owner.give_back(self.index, slot, self.billed);
513        }
514    }
515}
516
517impl WorkspaceProvider for ArenaProvider {
518    fn with_worker(&self, req: &WorkspaceReq, f: &mut dyn FnMut(*mut u8, *mut u8, &mut Vec<i64>)) {
519        let Some((index, slot, billed)) = self.checkout() else {
520            return fresh_worker(req, f);
521        };
522        // Armed before any growth, so a preparation unwind returns the slot too.
523        let mut guard = SlotGuard {
524            owner: self,
525            index,
526            slot: Some(slot),
527            billed,
528        };
529        let slot = guard.slot.as_mut().expect("guard holds its slot");
530        slot.a.ensure(req.a_bytes);
531        slot.tile.ensure(req.tile_bytes);
532        slot.scratch.clear();
533        slot.scratch.reserve(req.worker_scatter);
534        let (a, tile) = (slot.a.as_ptr(), slot.tile.as_ptr());
535        f(a, tile, &mut slot.scratch);
536    }
537
538    fn take_team(&self, req: &WorkspaceReq, pm: usize, pn: usize) -> TeamLease<'_> {
539        let (mut set, before, before_panel) = {
540            let mut st = lock(&self.state);
541            let set = st.teams.pop().unwrap_or_default();
542            let (bytes, panel) = (set.bytes(), set.b.cap_bytes());
543            (set, bytes, panel)
544        };
545        // `prepare` allocates, so it can panic. A panic drops `set` and its
546        // pages, and the owner has to stop counting the capacity it had when it
547        // was popped; the guard arms before `prepare` for that reason.
548        let mut teardown = Teardown {
549            owner: self,
550            before,
551            before_panel,
552            armed: true,
553        };
554        set.prepare(req, pm, pn);
555        let after = set.bytes();
556        teardown.armed = false;
557        let mut st = lock(&self.state);
558        st.owned = add_signed(st.owned, after as isize - before as isize, "retained bytes");
559        // `prepare` touches the scatter vector and the barriers, never the panel.
560        st.leased += after;
561        drop(st);
562        TeamLease {
563            owner: self,
564            set: Some(set),
565            billed: after,
566        }
567    }
568
569    fn trim(&self) {
570        let mut st = lock(&self.state);
571        let idle_slots: usize = st.workers.values().map(|s| s.bytes()).sum();
572        let idle_teams: usize = st.teams.iter().map(|t| t.bytes()).sum();
573        let idle_panels: usize = st.teams.iter().map(|t| t.b.cap_bytes()).sum();
574        for slot in st.workers.values_mut() {
575            slot.trim();
576        }
577        st.teams.clear();
578        st.owned = sub(st.owned, idle_slots + idle_teams, "retained bytes");
579        st.panel = sub(st.panel, idle_panels, "panel bytes");
580    }
581}
582
583/// Returns a popped team set's accounting when `prepare` unwinds.
584struct Teardown<'a> {
585    owner: &'a ArenaProvider,
586    before: usize,
587    before_panel: usize,
588    armed: bool,
589}
590
591impl Drop for Teardown<'_> {
592    fn drop(&mut self) {
593        if !self.armed {
594            return;
595        }
596        let mut st = lock(&self.owner.state);
597        st.owned = sub(st.owned, self.before, "retained bytes");
598        st.panel = sub(st.panel, self.before_panel, "panel bytes");
599    }
600}
601
602/// Buffers for one call that either re-entered its owner or has none. They are
603/// freed when the call returns, which is the price of re-entrancy, and they are
604/// not traced or accounted: the owner never holds them.
605fn fresh_worker(req: &WorkspaceReq, f: &mut dyn FnMut(*mut u8, *mut u8, &mut Vec<i64>)) {
606    let mut a = PageBuf::default();
607    let mut tile = PageBuf::default();
608    let mut scratch = Vec::new();
609    a.ensure(req.a_bytes);
610    tile.ensure(req.tile_bytes);
611    scratch.reserve(req.worker_scatter);
612    f(a.as_ptr(), tile.as_ptr(), &mut scratch);
613}