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}