Skip to main content

forge/backend/wgpu/
mod.rs

1//! WebGPU backend: device context, buffer management, pipeline cache, and
2//! kernel dispatch. Production backend of Forge; the CPU backend is the
3//! numerical reference.
4
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicUsize, Ordering};
7use std::sync::{Arc, Mutex};
8
9use wgpu::util::DeviceExt;
10
11use crate::error::{ForgeError, Result};
12
13pub mod ops;
14
15/// Storage-buffer offsets must respect this alignment when creating views.
16pub const OFFSET_ALIGN_BYTES: usize = 256;
17
18/// The forward kernels — every build has these.
19const SHADERS_CORE: &[(&str, &str)] = &[
20    ("add", include_str!("../../../shaders/add.wgsl")),
21    ("gelu", include_str!("../../../shaders/gelu.wgsl")),
22    ("matmul", include_str!("../../../shaders/matmul.wgsl")),
23    ("softmax", include_str!("../../../shaders/softmax.wgsl")),
24    ("layernorm", include_str!("../../../shaders/layernorm.wgsl")),
25    ("embedding", include_str!("../../../shaders/embedding.wgsl")),
26    (
27        "split_heads",
28        include_str!("../../../shaders/split_heads.wgsl"),
29    ),
30    (
31        "merge_heads",
32        include_str!("../../../shaders/merge_heads.wgsl"),
33    ),
34    ("kv_append", include_str!("../../../shaders/kv_append.wgsl")),
35];
36
37/// The backward and optimizer kernels. Split out of one table so `train` can
38/// gate them: `include_str!` bakes each shader's source into the binary, and
39/// a `const` slice referenced by `pipeline()` is live whether or not anything
40/// dispatches it. Splitting the table is what actually keeps ~14 KB of WGSL
41/// out of a default build — gating the Rust alone does not, because the
42/// linker had already eliminated that.
43#[cfg(feature = "train")]
44const SHADERS_TRAIN: &[(&str, &str)] = &[
45    ("gelu_bwd", include_str!("../../../shaders/gelu_bwd.wgsl")),
46    (
47        "softmax_bwd",
48        include_str!("../../../shaders/softmax_bwd.wgsl"),
49    ),
50    (
51        "layernorm_bwd_dx",
52        include_str!("../../../shaders/layernorm_bwd_dx.wgsl"),
53    ),
54    (
55        "layernorm_bwd_dp",
56        include_str!("../../../shaders/layernorm_bwd_dp.wgsl"),
57    ),
58    ("sum_rows", include_str!("../../../shaders/sum_rows.wgsl")),
59    (
60        "scatter_add",
61        include_str!("../../../shaders/scatter_add.wgsl"),
62    ),
63    (
64        "gather_nll",
65        include_str!("../../../shaders/gather_nll.wgsl"),
66    ),
67    ("ce_bwd", include_str!("../../../shaders/ce_bwd.wgsl")),
68    ("dropout", include_str!("../../../shaders/dropout.wgsl")),
69    (
70        "unsplit_heads",
71        include_str!("../../../shaders/unsplit_heads.wgsl"),
72    ),
73    (
74        "unmerge_heads",
75        include_str!("../../../shaders/unmerge_heads.wgsl"),
76    ),
77    ("sumsq", include_str!("../../../shaders/sumsq.wgsl")),
78    ("scale", include_str!("../../../shaders/scale.wgsl")),
79    ("adamw", include_str!("../../../shaders/adamw.wgsl")),
80];
81
82#[cfg(not(feature = "train"))]
83const SHADERS_TRAIN: &[(&str, &str)] = &[];
84
85/// Cumulative device counters, read with [`WgpuContext::stats`].
86///
87/// Always compiled rather than sitting behind a `bench` feature: they are four
88/// relaxed atomic increments on a path that is about to talk to a GPU, so they
89/// cost nothing measurable, and a counter nobody can read in a normal build is
90/// a counter that never catches the regression it exists for.
91///
92/// `dispatches` counts kernel invocations; `submits` counts
93/// `queue.submit` calls. Their ratio is the whole point — one submit per
94/// dispatch means the GPU is idling between kernels waiting on the CPU.
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
96pub struct Stats {
97    pub dispatches: usize,
98    pub submits: usize,
99    pub buffers_created: usize,
100    pub bytes_allocated: usize,
101}
102
103impl Stats {
104    /// Counters accumulated between two snapshots.
105    pub fn since(self, earlier: Stats) -> Stats {
106        Stats {
107            dispatches: self.dispatches - earlier.dispatches,
108            submits: self.submits - earlier.submits,
109            buffers_created: self.buffers_created - earlier.buffers_created,
110            bytes_allocated: self.bytes_allocated - earlier.bytes_allocated,
111        }
112    }
113}
114
115#[derive(Default)]
116struct Counters {
117    dispatches: AtomicUsize,
118    submits: AtomicUsize,
119    buffers_created: AtomicUsize,
120    bytes_allocated: AtomicUsize,
121}
122
123/// Buffers waiting to be handed out again, keyed by exact size in bytes.
124///
125/// Keyed exactly rather than by power-of-two size class on purpose: a
126/// transformer decode step allocates the *same* handful of shapes every token,
127/// so exact keys hit essentially always, while rounding a 41 MiB weight up to a
128/// 64 MiB class would waste more than the pool saves.
129#[derive(Default)]
130struct Pool {
131    free: HashMap<u64, Vec<wgpu::Buffer>>,
132    bytes: usize,
133}
134
135/// How much the free list may hold before returned buffers are dropped instead
136/// of kept. Bounds a long session; large one-off buffers (weights) are the ones
137/// this evicts, which is the right choice — they are never asked for twice.
138const MAX_POOL_BYTES: usize = 512 * 1024 * 1024;
139
140/// A storage buffer that returns to its context's free list when dropped
141/// rather than being destroyed.
142///
143/// Recycling GPU memory sounds alarming and is safe here for two reasons. A
144/// buffer only reaches the free list when its last host-side handle drops, and
145/// the pool can then only hand it to a *later* dispatch — and later commands on
146/// one queue execute after earlier ones, so a new write can never overtake a
147/// pending read. And recycled buffers are not zeroed, which is fine because
148/// every kernel writes the whole of its output before anything reads it;
149/// `tests/op_parity.rs` would show stale tail elements immediately if that ever
150/// stopped being true.
151///
152/// **`recycle` is the exception those reasons do not cover, and it is not
153/// theoretical — it corrupted `wte` gradients before it was understood.**
154/// `queue.write_buffer` does not run in command order: its data is applied at
155/// the *start* of the next submit, ahead of every command buffer in it. So a
156/// buffer that is dropped, recycled, and then filled by `upload` while earlier
157/// dispatches reading its previous contents are still recorded and unsubmitted
158/// would have those dispatches read the new bytes. Buffers written by
159/// `write_buffer` therefore never join the pool: [`WgpuContext::upload`] sets
160/// `recycle: false`. They are weights and per-step id tensors, so the pool
161/// loses almost nothing.
162pub struct PooledBuffer {
163    buf: Option<wgpu::Buffer>,
164    ctx: Arc<WgpuContext>,
165    size: u64,
166    recycle: bool,
167}
168
169impl std::ops::Deref for PooledBuffer {
170    type Target = wgpu::Buffer;
171    fn deref(&self) -> &wgpu::Buffer {
172        // Only `Drop` ever takes it, and `Drop` cannot be observed.
173        self.buf.as_ref().expect("PooledBuffer used after drop")
174    }
175}
176
177impl std::fmt::Debug for PooledBuffer {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        write!(f, "PooledBuffer({} B)", self.size)
180    }
181}
182
183impl Drop for PooledBuffer {
184    fn drop(&mut self) {
185        let Some(buf) = self.buf.take() else { return };
186        if !self.recycle {
187            return;
188        }
189        let mut pool = self.ctx.pool.lock().unwrap();
190        if pool.bytes + self.size as usize > MAX_POOL_BYTES {
191            return; // over the cap: let wgpu destroy it
192        }
193        pool.bytes += self.size as usize;
194        pool.free.entry(self.size).or_default().push(buf);
195    }
196}
197
198/// An open recording scope: every dispatch made while one is alive goes into a
199/// single command encoder and a single `queue.submit`.
200///
201/// The reason this type exists: a GPT-2 block issues ~16 kernels, and one
202/// submit each leaves the GPU idle between them waiting on the CPU. See
203/// [`WgpuContext::scope`].
204pub struct DispatchScope {
205    ctx: Arc<WgpuContext>,
206}
207
208impl Drop for DispatchScope {
209    fn drop(&mut self) {
210        let outermost = {
211            let mut s = self.ctx.scope.lock().unwrap();
212            s.depth -= 1;
213            s.depth == 0
214        };
215        if outermost {
216            self.ctx.flush();
217        }
218    }
219}
220
221#[derive(Default)]
222struct ScopeState {
223    encoder: Option<wgpu::CommandEncoder>,
224    /// One compute pass for the whole scope, rather than one per dispatch.
225    ///
226    /// A pass boundary is a pipeline barrier and, on some drivers, a cache
227    /// flush; ~100 of them per decoded token is real time. `forget_lifetime`
228    /// is what lets the pass and the encoder it borrows live in the same
229    /// struct — it is a safe wgpu API that turns the borrow into a runtime
230    /// check, and the check is upheld here by ending the pass (dropping it)
231    /// before the encoder is touched for anything else.
232    ///
233    /// Ordering within a pass is still guaranteed: dispatches execute in the
234    /// order recorded, and wgpu inserts the barriers a read-after-write
235    /// between them needs.
236    pass: Option<wgpu::ComputePass<'static>>,
237    depth: usize,
238}
239
240/// A compiled kernel: the pipeline, and the bind-group layout `dispatch` needs
241/// for every bind group it builds.
242///
243/// The layout is cached rather than fetched because
244/// `ComputePipeline::get_bind_group_layout` constructs a new one on every call,
245/// and `dispatch` would call it ~100 times per decoded token.
246type Kernel = (Arc<wgpu::ComputePipeline>, Arc<wgpu::BindGroupLayout>);
247
248/// Owns the wgpu device/queue and a cache of compiled compute pipelines.
249pub struct WgpuContext {
250    pub device: wgpu::Device,
251    pub queue: wgpu::Queue,
252    pub adapter_info: wgpu::AdapterInfo,
253    pipelines: Mutex<HashMap<&'static str, Kernel>>,
254    counters: Counters,
255    pool: Mutex<Pool>,
256    scope: Mutex<ScopeState>,
257}
258
259impl std::fmt::Debug for WgpuContext {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        write!(f, "WgpuContext({})", self.adapter_info.name)
262    }
263}
264
265impl WgpuContext {
266    /// Sync device creation — native only. On wasm use [`Self::new_async`].
267    #[cfg(not(target_arch = "wasm32"))]
268    pub fn new() -> Result<Arc<Self>> {
269        pollster::block_on(Self::new_async())
270    }
271
272    /// Async device creation (works on native and wasm32; roadmap v4,
273    /// pitfall 14: the async form is primary, the sync API is the facade).
274    pub async fn new_async() -> Result<Arc<Self>> {
275        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
276        let adapter = instance
277            .request_adapter(&wgpu::RequestAdapterOptions {
278                power_preference: wgpu::PowerPreference::HighPerformance,
279                ..Default::default()
280            })
281            .await
282            .map_err(|e| ForgeError::Wgpu(format!("no adapter: {e}")))?;
283        let adapter_info = adapter.get_info();
284        // GPT-2's token embedding (~147 MiB) exceeds the 128 MiB default
285        // max_storage_buffer_binding_size, so request the adapter's limits.
286        let (device, queue) = adapter
287            .request_device(&wgpu::DeviceDescriptor {
288                label: Some("forge"),
289                required_limits: adapter.limits(),
290                ..Default::default()
291            })
292            .await
293            .map_err(|e| ForgeError::Wgpu(format!("request_device: {e}")))?;
294        Ok(Arc::new(WgpuContext {
295            device,
296            queue,
297            adapter_info,
298            pipelines: Mutex::new(HashMap::new()),
299            counters: Counters::default(),
300            pool: Mutex::new(Pool::default()),
301            scope: Mutex::new(ScopeState::default()),
302        }))
303    }
304
305    /// Open a recording scope: every [`WgpuContext::dispatch`] made while the
306    /// returned guard is alive is recorded into one command encoder and
307    /// submitted once, when the outermost guard drops.
308    ///
309    /// This is the difference between ~100 submits per decoded token and one.
310    /// Scopes nest — only the outermost submits — so a caller can wrap a whole
311    /// decode step without knowing whether its callees also wrap their bodies.
312    ///
313    /// Reads are safe across a scope: any readback flushes first (see
314    /// [`WgpuContext::flush`]), and compute passes within one encoder execute
315    /// in the order they were recorded.
316    pub fn scope(self: &Arc<Self>) -> DispatchScope {
317        let mut s = self.scope.lock().unwrap();
318        s.depth += 1;
319        DispatchScope { ctx: self.clone() }
320    }
321
322    /// Submit whatever a scope has recorded so far, without closing it.
323    ///
324    /// Every readback path calls this first. Making it the readback's job
325    /// rather than the caller's is deliberate: "remember to flush before you
326    /// read" is exactly the kind of rule that silently rots into a
327    /// stale-bytes bug, and the cost is one uncontended mutex on a path that
328    /// is about to wait on a fence anyway.
329    pub fn flush(&self) {
330        let encoder = {
331            let mut s = self.scope.lock().unwrap();
332            s.pass = None; // ends the pass; the encoder is locked while one lives
333            s.encoder.take()
334        };
335        if let Some(encoder) = encoder {
336            self.submit(encoder);
337        }
338    }
339
340    /// Submit everything a scope has recorded *plus* these buffer-to-buffer
341    /// copies, in one command buffer.
342    ///
343    /// Readback is why this exists. Flushing the compute and then submitting
344    /// the staging copy separately costs two submits and two trips through the
345    /// driver for what is logically one step; folding the copy into the same
346    /// command buffer makes a decode step one submit and one fence wait.
347    ///
348    /// Each copy is `(src, src_offset_bytes, dst, size_bytes)`, written to
349    /// offset 0 of `dst`.
350    fn submit_with_copies(&self, copies: &[(&wgpu::Buffer, u64, &wgpu::Buffer, u64)]) {
351        let mut encoder = {
352            let mut s = self.scope.lock().unwrap();
353            s.pass = None;
354            s.encoder.take()
355        }
356        .unwrap_or_else(|| self.device.create_command_encoder(&Default::default()));
357        for (src, src_off, dst, size) in copies {
358            encoder.copy_buffer_to_buffer(src, *src_off, dst, 0, *size);
359        }
360        self.submit(encoder);
361    }
362
363    /// A storage buffer, from the free list when one of this exact size is
364    /// waiting. See [`PooledBuffer`].
365    pub fn create_pooled(self: &Arc<Self>, size_bytes: usize) -> PooledBuffer {
366        let size = size_bytes.max(4).next_multiple_of(4) as u64;
367        let recycled = {
368            let mut pool = self.pool.lock().unwrap();
369            let hit = pool.free.get_mut(&size).and_then(Vec::pop);
370            if hit.is_some() {
371                pool.bytes -= size as usize;
372            }
373            hit
374        };
375        let buf = recycled.unwrap_or_else(|| self.create_storage(size as usize));
376        PooledBuffer {
377            buf: Some(buf),
378            ctx: self.clone(),
379            size,
380            recycle: true,
381        }
382    }
383
384    /// Snapshot of this device's cumulative counters. Subtract two snapshots
385    /// with [`Stats::since`] to measure one region.
386    pub fn stats(&self) -> Stats {
387        Stats {
388            dispatches: self.counters.dispatches.load(Ordering::Relaxed),
389            submits: self.counters.submits.load(Ordering::Relaxed),
390            buffers_created: self.counters.buffers_created.load(Ordering::Relaxed),
391            bytes_allocated: self.counters.bytes_allocated.load(Ordering::Relaxed),
392        }
393    }
394
395    /// The one place a command buffer reaches the queue. Everything submits
396    /// through here so `Stats::submits` cannot drift from reality.
397    fn submit(&self, encoder: wgpu::CommandEncoder) {
398        self.counters.submits.fetch_add(1, Ordering::Relaxed);
399        self.queue.submit([encoder.finish()]);
400    }
401
402    /// The compiled pipeline for `name`, and its bind-group layout.
403    ///
404    /// The layout is cached alongside the pipeline because
405    /// `ComputePipeline::get_bind_group_layout` is not an accessor — it
406    /// constructs a new `BindGroupLayout` on every call, and `dispatch` calls
407    /// it once per kernel. At ~100 kernels per decoded token that is pure
408    /// CPU-side overhead on the critical path.
409    fn pipeline(&self, name: &'static str) -> Kernel {
410        let mut cache = self.pipelines.lock().unwrap();
411        cache
412            .entry(name)
413            .or_insert_with(|| {
414                let src = SHADERS_CORE
415                    .iter()
416                    .chain(SHADERS_TRAIN)
417                    .find(|(n, _)| *n == name)
418                    .unwrap_or_else(|| panic!("unknown shader {name}"))
419                    .1;
420                let module = self
421                    .device
422                    .create_shader_module(wgpu::ShaderModuleDescriptor {
423                        label: Some(name),
424                        source: wgpu::ShaderSource::Wgsl(src.into()),
425                    });
426                let pipeline =
427                    self.device
428                        .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
429                            label: Some(name),
430                            layout: None,
431                            module: &module,
432                            entry_point: Some("main"),
433                            compilation_options: Default::default(),
434                            cache: None,
435                        });
436                let layout = Arc::new(pipeline.get_bind_group_layout(0));
437                (Arc::new(pipeline), layout)
438            })
439            .clone()
440    }
441
442    pub fn create_storage(&self, size_bytes: usize) -> wgpu::Buffer {
443        let size = size_bytes.max(4) as u64;
444        self.counters
445            .buffers_created
446            .fetch_add(1, Ordering::Relaxed);
447        self.counters
448            .bytes_allocated
449            .fetch_add(size as usize, Ordering::Relaxed);
450        self.device.create_buffer(&wgpu::BufferDescriptor {
451            label: None,
452            size,
453            usage: wgpu::BufferUsages::STORAGE
454                | wgpu::BufferUsages::COPY_DST
455                | wgpu::BufferUsages::COPY_SRC,
456            mapped_at_creation: false,
457        })
458    }
459
460    /// A storage buffer that never came from the pool, and never returns to
461    /// it — so it is guaranteed zero-filled, which WebGPU promises for a newly
462    /// created buffer and a recycled one obviously cannot.
463    ///
464    /// For the kernels that write only part of their output and rely on the
465    /// rest being zero. There is exactly one today (`unsplit_heads`, which
466    /// fills one third of a `[t, 3c]` gradient), and it is worth a dedicated
467    /// allocator rather than zeroing every recycled buffer: zeroing through
468    /// `queue.write_buffer` would reintroduce the ordering hazard described on
469    /// [`PooledBuffer`], and clearing through the encoder would force a pass
470    /// boundary on a path that has no other reason for one.
471    pub fn create_zeroed(self: &Arc<Self>, size_bytes: usize) -> PooledBuffer {
472        PooledBuffer {
473            buf: Some(self.create_storage(size_bytes.max(4))),
474            ctx: self.clone(),
475            size: size_bytes as u64,
476            recycle: false,
477        }
478    }
479
480    /// A fresh storage buffer holding `bytes`.
481    ///
482    /// Deliberately outside the pool in both directions — it neither takes a
483    /// recycled buffer nor returns this one. See [`PooledBuffer`]: a
484    /// `queue.write_buffer` into a recycled buffer can land ahead of recorded
485    /// dispatches that still expect its old contents.
486    pub fn upload(self: &Arc<Self>, bytes: &[u8]) -> PooledBuffer {
487        let buf = self.create_storage(bytes.len().max(4));
488        self.queue.write_buffer(&buf, 0, bytes);
489        PooledBuffer {
490            buf: Some(buf),
491            ctx: self.clone(),
492            size: bytes.len() as u64,
493            recycle: false,
494        }
495    }
496
497    fn stage_copy(
498        &self,
499        buf: &wgpu::Buffer,
500        offset_bytes: usize,
501        size_bytes: usize,
502    ) -> wgpu::Buffer {
503        let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
504            label: None,
505            size: size_bytes as u64,
506            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
507            mapped_at_creation: false,
508        });
509        // Whatever a scope has recorded must reach the GPU ahead of this copy,
510        // or the read returns stale bytes. Going through `submit_with_copies`
511        // rather than `flush` then a second submit is what keeps a decode step
512        // at one submit and one fence wait.
513        self.submit_with_copies(&[(buf, offset_bytes as u64, &staging, size_bytes as u64)]);
514        staging
515    }
516
517    /// Read `size_bytes` starting at `offset_bytes` back to the host.
518    /// Sync facade — native only (`device.poll(Wait)` cannot exist on wasm).
519    #[cfg(not(target_arch = "wasm32"))]
520    pub fn readback(
521        &self,
522        buf: &wgpu::Buffer,
523        offset_bytes: usize,
524        size_bytes: usize,
525    ) -> Result<Vec<u8>> {
526        let staging = self.stage_copy(buf, offset_bytes, size_bytes);
527        let slice = staging.slice(..);
528        let (tx, rx) = std::sync::mpsc::channel();
529        slice.map_async(wgpu::MapMode::Read, move |r| {
530            let _ = tx.send(r);
531        });
532        self.device
533            .poll(wgpu::PollType::Wait)
534            .map_err(|e| ForgeError::Wgpu(format!("poll: {e:?}")))?;
535        rx.recv()
536            .map_err(|_| ForgeError::Wgpu("map_async callback dropped".into()))?
537            .map_err(|e| ForgeError::Wgpu(format!("map_async: {e:?}")))?;
538        let out = slice.get_mapped_range().to_vec();
539        staging.unmap();
540        Ok(out)
541    }
542
543    /// Async readback — the primary form; on wasm the browser event loop
544    /// drives the mapping.
545    pub async fn readback_async(
546        &self,
547        buf: &wgpu::Buffer,
548        offset_bytes: usize,
549        size_bytes: usize,
550    ) -> Result<Vec<u8>> {
551        let staging = self.stage_copy(buf, offset_bytes, size_bytes);
552        let slice = staging.slice(..);
553        let (tx, rx) = oneshot::channel();
554        slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r));
555        #[cfg(not(target_arch = "wasm32"))]
556        self.device
557            .poll(wgpu::PollType::Wait)
558            .map_err(|e| ForgeError::Wgpu(format!("poll: {e:?}")))?;
559        #[cfg(target_arch = "wasm32")]
560        let _ = self.device.poll(wgpu::PollType::Poll);
561        rx.await
562            .map_err(|e| ForgeError::Wgpu(format!("map_async: {e:?}")))?;
563        let out = slice.get_mapped_range().to_vec();
564        staging.unmap();
565        Ok(out)
566    }
567
568    /// Read several regions back in one submit and one fence wait.
569    ///
570    /// [`WgpuContext::readback_async`] costs a submit and a wait *each*, which
571    /// dominates when a single logical step wants several small tensors: the
572    /// attention probe reads `n_layer + 1` per generated token, and one at a
573    /// time that cost more than the decode itself. Staged into one encoder
574    /// they cost one round trip regardless of how many there are.
575    ///
576    /// Regions are returned in the order given.
577    pub async fn readback_many_async(
578        &self,
579        regions: &[(&wgpu::Buffer, usize, usize)],
580    ) -> Result<Vec<Vec<u8>>> {
581        if regions.is_empty() {
582            return Ok(Vec::new());
583        }
584        let staging: Vec<wgpu::Buffer> = regions
585            .iter()
586            .map(|(_, _, size_bytes)| {
587                self.device.create_buffer(&wgpu::BufferDescriptor {
588                    label: None,
589                    size: *size_bytes as u64,
590                    usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
591                    mapped_at_creation: false,
592                })
593            })
594            .collect();
595        // See `stage_copy`: pending scope work and every one of these copies go
596        // out in one command buffer, so N regions still cost one round trip.
597        let copies: Vec<_> = regions
598            .iter()
599            .zip(&staging)
600            .map(|((buf, off, size), s)| (*buf, *off as u64, s, *size as u64))
601            .collect();
602        self.submit_with_copies(&copies);
603
604        // Every map request is issued before anything is awaited, so one poll
605        // services all of them.
606        let waits: Vec<_> = staging
607            .iter()
608            .map(|s| {
609                let (tx, rx) = oneshot::channel();
610                s.slice(..)
611                    .map_async(wgpu::MapMode::Read, move |r| tx.send(r));
612                rx
613            })
614            .collect();
615        #[cfg(not(target_arch = "wasm32"))]
616        self.device
617            .poll(wgpu::PollType::Wait)
618            .map_err(|e| ForgeError::Wgpu(format!("poll: {e:?}")))?;
619        #[cfg(target_arch = "wasm32")]
620        let _ = self.device.poll(wgpu::PollType::Poll);
621
622        let mut out = Vec::with_capacity(regions.len());
623        for (rx, s) in waits.into_iter().zip(&staging) {
624            rx.await
625                .map_err(|e| ForgeError::Wgpu(format!("map_async: {e:?}")))?;
626            out.push(s.slice(..).get_mapped_range().to_vec());
627            s.unmap();
628        }
629        Ok(out)
630    }
631
632    /// Dispatch `name` with binding 0 = `params` (uniform, raw words) and
633    /// bindings 1.. = `buffers` (storage). Each buffer entry is
634    /// (buffer, offset_bytes, size_bytes).
635    pub fn dispatch(
636        &self,
637        name: &'static str,
638        params: &[u32],
639        buffers: &[(&wgpu::Buffer, usize, usize)],
640        workgroups: (u32, u32, u32),
641    ) {
642        self.counters.dispatches.fetch_add(1, Ordering::Relaxed);
643        let (pipeline, layout) = self.pipeline(name);
644        let params_buf = self
645            .device
646            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
647                label: Some(name),
648                contents: bytemuck::cast_slice(params),
649                usage: wgpu::BufferUsages::UNIFORM,
650            });
651        let mut entries = vec![wgpu::BindGroupEntry {
652            binding: 0,
653            resource: params_buf.as_entire_binding(),
654        }];
655        for (i, (buf, off, size)) in buffers.iter().enumerate() {
656            debug_assert!(off % OFFSET_ALIGN_BYTES == 0, "storage offset misaligned");
657            entries.push(wgpu::BindGroupEntry {
658                binding: (i + 1) as u32,
659                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
660                    buffer: buf,
661                    offset: *off as u64,
662                    size: Some(std::num::NonZeroU64::new((*size).max(4) as u64).unwrap()),
663                }),
664            });
665        }
666        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
667            label: Some(name),
668            layout: &layout,
669            entries: &entries,
670        });
671        // Inside a scope this records into the shared encoder and pass and
672        // returns; the submit happens once, when the outermost scope drops.
673        // Outside one the behaviour is exactly what it has always been, which
674        // is what lets this change land without touching a single caller.
675        let mut scope = self.scope.lock().unwrap();
676        if scope.depth > 0 {
677            let ScopeState { encoder, pass, .. } = &mut *scope;
678            // `flush` may have taken both mid-scope; remake what is missing.
679            let encoder = encoder
680                .get_or_insert_with(|| self.device.create_command_encoder(&Default::default()));
681            let pass = match pass {
682                Some(p) => p,
683                None => pass.insert(
684                    encoder
685                        .begin_compute_pass(&Default::default())
686                        .forget_lifetime(),
687                ),
688            };
689            pass.set_pipeline(&pipeline);
690            pass.set_bind_group(0, &bind, &[]);
691            pass.dispatch_workgroups(workgroups.0, workgroups.1, workgroups.2);
692            return;
693        }
694        drop(scope);
695
696        let mut encoder = self.device.create_command_encoder(&Default::default());
697        {
698            let mut pass = encoder.begin_compute_pass(&Default::default());
699            pass.set_pipeline(&pipeline);
700            pass.set_bind_group(0, &bind, &[]);
701            pass.dispatch_workgroups(workgroups.0, workgroups.1, workgroups.2);
702        }
703        self.submit(encoder);
704    }
705}
706
707/// Minimal single-value channel whose receiver is a `Future` — lets
708/// `map_async` results be awaited without extra dependencies (wasm has no
709/// blocking receive).
710mod oneshot {
711    use std::future::Future;
712    use std::pin::Pin;
713    use std::sync::{Arc, Mutex};
714    use std::task::{Context, Poll, Waker};
715
716    struct State<T> {
717        value: Option<T>,
718        waker: Option<Waker>,
719    }
720
721    pub struct Sender<T>(Arc<Mutex<State<T>>>);
722    pub struct Receiver<T>(Arc<Mutex<State<T>>>);
723
724    pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
725        let shared = Arc::new(Mutex::new(State {
726            value: None,
727            waker: None,
728        }));
729        (Sender(shared.clone()), Receiver(shared))
730    }
731
732    impl<T> Sender<T> {
733        pub fn send(self, value: T) {
734            let mut s = self.0.lock().unwrap();
735            s.value = Some(value);
736            if let Some(w) = s.waker.take() {
737                w.wake();
738            }
739        }
740    }
741
742    impl<T> Future for Receiver<T> {
743        type Output = T;
744        fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<T> {
745            let mut s = self.0.lock().unwrap();
746            match s.value.take() {
747                Some(v) => Poll::Ready(v),
748                None => {
749                    s.waker = Some(cx.waker().clone());
750                    Poll::Pending
751                }
752            }
753        }
754    }
755}
756
757/// Split a linear element count into a (x, y, 1) workgroup grid of 256-thread
758/// groups, respecting the 65535 per-dimension dispatch limit.
759pub fn linear_grid(numel: usize) -> (u32, u32, u32) {
760    let groups = numel.div_ceil(256).max(1) as u32;
761    if groups <= 65535 {
762        (groups, 1, 1)
763    } else {
764        let y = groups.div_ceil(65535);
765        (65535, y, 1)
766    }
767}