From e267553e590b432f8ccee35849a50899375af3f7 Mon Sep 17 00:00:00 2001 From: dafioram Date: Tue, 3 Nov 2026 14:04:13 +0000 Subject: [PATCH 01/12] Explain why DX12 FP16 is skipped On DX12, wgpu advertises SHADER_F16 only with shader model 6.2 or newer and native 16-bit shader support. FXC cannot compile for shader model 6.2, so without dxcompiler.dll the FP16 matmul is skipped. The skip message now says so when the run is DX12 and no DXC is found. With DXC loaded, it says the driver reports neither shader model 6.2 nor native 16-bit support. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- src/bench.rs | 29 ++++++++++++++++++++++++++++- src/gpu.rs | 8 +++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/bench.rs b/src/bench.rs index 8a104ed..41fe9db 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -26,6 +26,21 @@ fn budget_reached(spent: f64, samples: usize) -> bool { samples >= 1 && spent >= MEASURE_BUDGET } +/// Why the FP16 matmul is skipped. On DX12, wgpu advertises FP16 only with +/// shader model 6.2 or newer, which FXC cannot compile for (it stops at 5.1). +fn f16_skip_detail(dx12: bool, dxc: bool) -> String { + match (dx12, dxc) { + (true, false) => "adapter does not support SHADER_F16. This DX12 run uses FXC, which \ + cannot compile FP16 shaders. Put dxcompiler.dll next to the program to \ + enable it (see the README)." + .into(), + (true, true) => "adapter does not support SHADER_F16, even with DXC loaded: the \ + driver reports neither shader model 6.2 nor native 16-bit shader support." + .into(), + (false, _) => "adapter does not support SHADER_F16".into(), + } +} + /// How far the stress throughput fell from start to end, in words. A speed-up /// reads as a gain, not as a negative drop. fn trend_text(fall_pct: f64) -> String { @@ -1061,11 +1076,12 @@ impl Bench { Test::MatmulF32 }; if f16 && !self.g.has_f16 { + let dx12 = self.g.info.backend == wgpu::Backend::Dx12; return Ok(TestResult { test, value: 0.0, unit: "", - detail: "adapter does not support SHADER_F16".into(), + detail: f16_skip_detail(dx12, gpu::dxc_loadable()), status: Status::Skipped, }); } @@ -1576,6 +1592,17 @@ mod tests { assert!(budget_reached(MEASURE_BUDGET, 1)); } + #[test] + fn fp16_skip_names_the_cause_on_dx12() { + assert!(f16_skip_detail(true, false).contains("dxcompiler.dll")); + assert!(f16_skip_detail(true, false).contains("FXC")); + assert!(f16_skip_detail(true, true).contains("shader model 6.2")); + assert_eq!( + f16_skip_detail(false, false), + "adapter does not support SHADER_F16" + ); + } + #[test] fn stress_trend_reads_as_drop_or_gain() { assert_eq!(trend_text(9.94), "9.9% drop"); diff --git a/src/gpu.rs b/src/gpu.rs index 073db48..da62124 100644 --- a/src/gpu.rs +++ b/src/gpu.rs @@ -326,7 +326,7 @@ pub fn dx12_compiler_note() -> Option { /// Whether wgpu can load dxcompiler.dll. Uses the same name and search order, /// so the answer matches what wgpu will do. #[cfg(windows)] -fn dxc_loadable() -> bool { +pub fn dxc_loadable() -> bool { use windows::Win32::Foundation::FreeLibrary; use windows::Win32::System::LibraryLoader::LoadLibraryW; use windows::core::w; @@ -342,6 +342,12 @@ fn dxc_loadable() -> bool { } } +/// There is no DXC outside Windows. +#[cfg(not(windows))] +pub fn dxc_loadable() -> bool { + false +} + pub fn fmt_bytes(b: u64) -> String { const K: f64 = 1024.0; let b = b as f64; From 729658ce03d75a07c0ae31adf522fea2190c36c0 Mon Sep 17 00:00:00 2001 From: dafioram Date: Tue, 3 Nov 2026 14:04:13 +0000 Subject: [PATCH 02/12] Add a DX12 FP16 experiment on WARP, with and without DXC Runs the FP16 matmul on the software D3D12 device twice: with FXC, then with the dxcompiler.dll found in the Windows SDK. It only runs when this file changes, and it is not part of the build. Drop it once the question is answered. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- .github/workflows/dx12-f16-experiment.yml | 65 +++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/dx12-f16-experiment.yml diff --git a/.github/workflows/dx12-f16-experiment.yml b/.github/workflows/dx12-f16-experiment.yml new file mode 100644 index 0000000..16c5904 --- /dev/null +++ b/.github/workflows/dx12-f16-experiment.yml @@ -0,0 +1,65 @@ +# Experiment, not part of the build. Question: does DX12 FP16 need DXC? +# +# Runs the FP16 matmul on WARP (GitHub's software D3D12 device) twice: +# 1. with FXC: no dxcompiler.dll next to the program +# 2. with DXC: dxcompiler.dll copied from the Windows SDK next to the program +# If run 2 passes FP16 and run 1 skips it, the shader model is the cause. +# If both skip, WARP has no native 16-bit shader support and proves nothing +# about the card. +name: dx12-f16-experiment + +on: + push: + paths: + - ".github/workflows/dx12-f16-experiment.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + f16: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build the console CLI + run: cargo build --release --bin gpu-workbench-cli + + - name: Find dxcompiler.dll in the Windows SDK + shell: pwsh + run: | + $dll = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter dxcompiler.dll -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $dll) { throw "no x64 dxcompiler.dll in the Windows SDK" } + Write-Host "dxcompiler.dll: $($dll.FullName) version $($dll.VersionInfo.FileVersion)" + $dir = Join-Path $env:RUNNER_TEMP "dxc-run" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + Copy-Item $dll.FullName $dir -Force + $dxil = Join-Path $dll.DirectoryName "dxil.dll" + if (Test-Path $dxil) { Copy-Item $dxil $dir -Force; Write-Host "dxil.dll copied too" } + Copy-Item "target\release\gpu-workbench-cli.exe" $dir -Force + Write-Host "contents of the DXC run folder:" + Get-ChildItem $dir | ForEach-Object { Write-Host " $($_.Name)" } + + - name: Run 1, FXC (no dxcompiler.dll next to the program) + shell: pwsh + env: + WGPU_BACKEND: dx12 + run: | + if (Test-Path "target\release\dxcompiler.dll") { throw "unexpected dxcompiler.dll next to the build" } + & .\target\release\gpu-workbench-cli.exe --tests matmul32,matmul16 --matmul-n 256 --transfer-mb 16 + Write-Host "exit code: $LASTEXITCODE" + + - name: Run 2, DXC (dxcompiler.dll next to the program) + shell: pwsh + env: + WGPU_BACKEND: dx12 + run: | + $dir = Join-Path $env:RUNNER_TEMP "dxc-run" + & (Join-Path $dir "gpu-workbench-cli.exe") --tests matmul32,matmul16 --matmul-n 256 --transfer-mb 16 + Write-Host "exit code: $LASTEXITCODE" From 1ff4a5f0582395590529be58d78c6f4d5f85d150 Mon Sep 17 00:00:00 2001 From: dafioram Date: Tue, 3 Nov 2026 20:45:00 +0000 Subject: [PATCH 03/12] Warn that dxcompiler.dll can hide DX12 GPUs; show the compiler in --list With dxcompiler.dll present, wgpu drops a DX12 adapter whose driver lacks shader model 6.0, rather than falling back to FXC. The DXC note now says so, the README warns about it, and --list prints the DX12 compiler in use, so a run with and without the DLLs can be compared directly. Removes the FP16 experiment workflow, which has answered its question. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- .github/workflows/dx12-f16-experiment.yml | 65 ----------------------- README.md | 5 ++ src/cli.rs | 3 ++ src/gpu.rs | 4 +- 4 files changed, 11 insertions(+), 66 deletions(-) delete mode 100644 .github/workflows/dx12-f16-experiment.yml diff --git a/.github/workflows/dx12-f16-experiment.yml b/.github/workflows/dx12-f16-experiment.yml deleted file mode 100644 index 16c5904..0000000 --- a/.github/workflows/dx12-f16-experiment.yml +++ /dev/null @@ -1,65 +0,0 @@ -# Experiment, not part of the build. Question: does DX12 FP16 need DXC? -# -# Runs the FP16 matmul on WARP (GitHub's software D3D12 device) twice: -# 1. with FXC: no dxcompiler.dll next to the program -# 2. with DXC: dxcompiler.dll copied from the Windows SDK next to the program -# If run 2 passes FP16 and run 1 skips it, the shader model is the cause. -# If both skip, WARP has no native 16-bit shader support and proves nothing -# about the card. -name: dx12-f16-experiment - -on: - push: - paths: - - ".github/workflows/dx12-f16-experiment.yml" - workflow_dispatch: - -permissions: - contents: read - -jobs: - f16: - runs-on: windows-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Build the console CLI - run: cargo build --release --bin gpu-workbench-cli - - - name: Find dxcompiler.dll in the Windows SDK - shell: pwsh - run: | - $dll = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter dxcompiler.dll -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -match '\\x64\\' } | - Sort-Object FullName -Descending | - Select-Object -First 1 - if (-not $dll) { throw "no x64 dxcompiler.dll in the Windows SDK" } - Write-Host "dxcompiler.dll: $($dll.FullName) version $($dll.VersionInfo.FileVersion)" - $dir = Join-Path $env:RUNNER_TEMP "dxc-run" - New-Item -ItemType Directory -Force -Path $dir | Out-Null - Copy-Item $dll.FullName $dir -Force - $dxil = Join-Path $dll.DirectoryName "dxil.dll" - if (Test-Path $dxil) { Copy-Item $dxil $dir -Force; Write-Host "dxil.dll copied too" } - Copy-Item "target\release\gpu-workbench-cli.exe" $dir -Force - Write-Host "contents of the DXC run folder:" - Get-ChildItem $dir | ForEach-Object { Write-Host " $($_.Name)" } - - - name: Run 1, FXC (no dxcompiler.dll next to the program) - shell: pwsh - env: - WGPU_BACKEND: dx12 - run: | - if (Test-Path "target\release\dxcompiler.dll") { throw "unexpected dxcompiler.dll next to the build" } - & .\target\release\gpu-workbench-cli.exe --tests matmul32,matmul16 --matmul-n 256 --transfer-mb 16 - Write-Host "exit code: $LASTEXITCODE" - - - name: Run 2, DXC (dxcompiler.dll next to the program) - shell: pwsh - env: - WGPU_BACKEND: dx12 - run: | - $dir = Join-Path $env:RUNNER_TEMP "dxc-run" - & (Join-Path $dir "gpu-workbench-cli.exe") --tests matmul32,matmul16 --matmul-n 256 --transfer-mb 16 - Write-Host "exit code: $LASTEXITCODE" diff --git a/README.md b/README.md index 98a47c9..706656d 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,11 @@ loaded. In PowerShell: $env:WGPU_DX12_COMPILER = "dxc" ``` +**Caution:** while `dxcompiler.dll` is next to the program, a GPU whose driver +does not support shader model 6.0 is hidden from the DX12 list (wgpu drops it +rather than fall back to FXC). Delete the DLLs to see that GPU again; it then +uses FXC. `gpu-workbench-cli --list` prints which compiler is in use. + ## Log files Every session writes a timestamped log: GPU and driver, sensor support, diff --git a/src/cli.rs b/src/cli.rs index cf6be5a..c72293d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -194,6 +194,9 @@ pub fn main(args: &[String]) -> i32 { out!(" {}", a.label()); } if list_only { + if let Some(note) = gpu::dx12_compiler_note() { + out!("{note}"); + } return 0; } diff --git a/src/gpu.rs b/src/gpu.rs index da62124..961d17e 100644 --- a/src/gpu.rs +++ b/src/gpu.rs @@ -310,7 +310,9 @@ pub fn dx12_compiler_note() -> Option { )); } Some(if dxc_loadable() { - "DX12 shaders: DXC (dxcompiler.dll found)".into() + "DX12 shaders: DXC (dxcompiler.dll found). While the DLL is present, GPUs whose \ + driver lacks shader model 6.0 are hidden from the DX12 list." + .into() } else { "DX12 shaders: dxcompiler.dll not found, falling back to FXC (slower). \ Put dxcompiler.dll next to the program; see the README." From f4bd5ae2aac8158e1a70115161d33ad2039e3f5c Mon Sep 17 00:00:00 2001 From: dafioram Date: Wed, 4 Nov 2026 13:10:23 +0000 Subject: [PATCH 04/12] Name the K block behind a wrong matmul entry A WARP matmul check failed with 243 of 256 samples wrong and a max error of 59.9, while uncut and llvmpipe runs pass. The log only said how many were wrong, not what the GPU computed instead. When a sampled entry is wrong, the first three are now logged with what the GPU value matches: one K block of the kernel (16 terms) left out or counted twice, a prefix of K summed alone, a never-written zero, or no simple partial sum. Passing runs print nothing extra. A unit test covers the matches. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- src/bench.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/src/bench.rs b/src/bench.rs index 41fe9db..baa9ce6 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -413,6 +413,46 @@ fn best_pass_gbs(bytes: u64, pass_secs: &[f64]) -> f64 { bytes as f64 / fastest.max(1e-9) / 1e9 } +/// Says what a wrong C entry holds. `terms` are the products a[r,k] * b[k,c] +/// and `gpu` is the value the shader wrote. If it equals the sum with one K +/// block of the matmul kernel (TK = 16) left out or counted twice, or with a +/// prefix of the K range left out, the shader skipped or repeated that work. +/// Otherwise the value matches no such partial sum. +fn explain_bad(gpu: f64, terms: &[f64]) -> String { + const TOL: f64 = 1e-3; + const TK: usize = 16; + if gpu.abs() <= TOL { + return "is zero (never written)".into(); + } + let total: f64 = terms.iter().sum(); + let mut hits = Vec::new(); + let mut prefix = 0.0; + for (i, block) in terms.chunks(TK).enumerate() { + let k0 = i * TK; + let k1 = k0 + block.len(); + let sum: f64 = block.iter().sum(); + if k0 > 0 && (prefix - gpu).abs() <= TOL { + hits.push(format!("only k < {k0} summed")); + } + if k0 > 0 && (total - prefix - gpu).abs() <= TOL { + hits.push(format!("k < {k0} missing")); + } + if (total - sum - gpu).abs() <= TOL { + hits.push(format!("k {k0}..{k1} missing")); + } + if (total + sum - gpu).abs() <= TOL { + hits.push(format!("k {k0}..{k1} counted twice")); + } + prefix += sum; + } + if hits.is_empty() { + "matches no simple partial sum: the values themselves are wrong".into() + } else { + hits.truncate(3); + hits.join("; ") + } +} + struct Kernel { pipeline: wgpu::ComputePipeline, layout: wgpu::BindGroupLayout, @@ -1055,15 +1095,24 @@ impl Bench { x ^= x >> 7; x ^= x << 17; let (r, col) = ((x as usize) % n, ((x >> 32) as usize) % n); - let want: f64 = (0..n) + let terms: Vec = (0..n) .map(|k| ha[r * n + k] as f64 * hb[k * n + col] as f64) - .sum(); - let err = (gpu[r * n + col] as f64 - want).abs(); + .collect(); + let want: f64 = terms.iter().sum(); + let got = gpu[r * n + col] as f64; + let err = (got - want).abs(); max_err = max_err.max(err); // FP32 sums are exact here; FP16 may round once partial sums pass 128. let tol = if f16 { 0.5 + want.abs() * 2e-3 } else { 1e-3 }; if err > tol || err.is_nan() { bad += 1; + // The first few misses say which part of the K sum went wrong. + if bad <= 3 { + self.log(format!( + " wrong C[{r}][{col}]: GPU {got:.4}, CPU {want:.4}: {}", + explain_bad(got, &terms) + )); + } } } Ok((bad, max_err)) @@ -1585,6 +1634,16 @@ mod tests { assert!((gbs - 4.0).abs() < 1e-9, "{gbs}"); } + #[test] + fn wrong_entries_are_traced_to_the_k_range() { + let ones = vec![1.0; 64]; // total 64, four K blocks of 16 + assert!(explain_bad(32.0, &ones).contains("only k < 32 summed")); + assert!(explain_bad(48.0, &ones).contains("k 0..16 missing")); + assert!(explain_bad(80.0, &ones).contains("counted twice")); + assert!(explain_bad(0.0, &ones).contains("never written")); + assert!(explain_bad(7.0, &ones).contains("no simple partial sum")); + } + #[test] fn time_budget_needs_a_sample_and_the_full_budget() { assert!(!budget_reached(MEASURE_BUDGET * 10.0, 0)); From cc1687ca47841bb76e88e9765bb92718763b0054 Mon Sep 17 00:00:00 2001 From: dafioram Date: Wed, 4 Nov 2026 13:50:10 +0000 Subject: [PATCH 05/12] Map which matmul tiles were written when the check fails The WARP run failed 243 of 256 samples, and the three logged entries were exactly zero, so most of C was never written even though the GPU ran the full matmul time with no reported error. The count of wrong samples cannot show which tiles are missing. On a failed check, the log now prints one character per 64x64 tile of C, from one sampled entry per tile: '.' never written, 'o' matches the CPU, 'x' wrong. Whole skipped tile rows or columns will be visible at a glance. Passing runs print nothing extra. A unit test covers the map. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- src/bench.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/bench.rs b/src/bench.rs index baa9ce6..526e08a 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -413,6 +413,31 @@ fn best_pass_gbs(bytes: u64, pass_secs: &[f64]) -> f64 { bytes as f64 / fastest.max(1e-9) / 1e9 } +/// One character per 64x64 tile of C, from one sampled entry per tile: '.' if +/// it is exactly zero (never written), 'o' if it matches the CPU, 'x' if not. +/// Shows whether whole tile rows or columns were skipped, or all are wrong. +fn tile_map(gpu: &[f32], ha: &[f32], hb: &[f32], n: usize, f16: bool) -> Vec { + let tiles = n / 64; + (0..tiles) + .map(|ti| { + (0..tiles) + .map(|tj| { + let (r, c) = (ti * 64 + 17, tj * 64 + 9); + let got = gpu[r * n + c] as f64; + if got == 0.0 { + return '.'; + } + let want: f64 = (0..n) + .map(|k| ha[r * n + k] as f64 * hb[k * n + c] as f64) + .sum(); + let tol = if f16 { 0.5 + want.abs() * 2e-3 } else { 1e-3 }; + if (got - want).abs() <= tol { 'o' } else { 'x' } + }) + .collect() + }) + .collect() +} + /// Says what a wrong C entry holds. `terms` are the products a[r,k] * b[k,c] /// and `gpu` is the value the shader wrote. If it equals the sum with one K /// block of the matmul kernel (TK = 16) left out or counted twice, or with a @@ -1115,6 +1140,13 @@ impl Bench { } } } + if bad > 0 { + // Which 64x64 tiles of C were written, and whether they are right. + self.log(" tile map: one character per 64x64 tile of C; . never written, o matches the CPU, x wrong"); + for row in tile_map(&gpu, ha, hb, n, f16) { + self.log(format!(" {row}")); + } + } Ok((bad, max_err)) } @@ -1634,6 +1666,26 @@ mod tests { assert!((gbs - 4.0).abs() < 1e-9, "{gbs}"); } + #[test] + fn tile_map_marks_missing_and_wrong_tiles() { + // 128x128 of ones times ones: every entry is 128. Tile (0,1) is never + // written, tile (1,0) holds a wrong value. + let n = 128; + let ones = vec![1.0f32; n * n]; + let mut gpu = vec![128.0f32; n * n]; + for r in 0..64 { + for c in 64..128 { + gpu[r * n + c] = 0.0; + } + } + for r in 64..128 { + for c in 0..64 { + gpu[r * n + c] = 5.0; + } + } + assert_eq!(tile_map(&gpu, &ones, &ones, n, false), ["o.", "xo"]); + } + #[test] fn wrong_entries_are_traced_to_the_k_range() { let ones = vec![1.0; 64]; // total 64, four K blocks of 16 From 18d5b1120ae6a28e83bb76b0f76836584f5f7575 Mon Sep 17 00:00:00 2001 From: dafioram Date: Wed, 4 Nov 2026 14:14:23 +0000 Subject: [PATCH 06/12] Create the matmul band offsets with their contents The WARP tile map showed only the bottom 64-row band written correctly; the other 31 bands were never written. Each dispatch still took its full time, so every dispatch was computing the same band, the last one. The 32 per-band uniforms were each set with queue.write_buffer before the first submit, so they appear to have resolved to the last band's offset. Each uniform is now created already holding its contents, so no write is pending. The values are the same; only the write path changes. This is not yet confirmed on WARP: the next WARP run's tile map should be all 'o'. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- src/bench.rs | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/bench.rs b/src/bench.rs index 526e08a..07483d1 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -561,6 +561,35 @@ impl Bench { size: u64, usage: wgpu::BufferUsages, label: &str, + ) -> Result { + self.create_checked(size, usage, label, false) + } + + /// A buffer that starts out holding `data`. Written at creation instead of + /// with queue.write_buffer: on the WARP device, the per-band uniforms of the + /// matmul all ended up holding the last band's offset. + fn try_buffer_with( + &self, + data: &[u8], + usage: wgpu::BufferUsages, + label: &str, + ) -> Result { + let buf = self.create_checked(data.len() as u64, usage, label, true)?; + buf.slice(..) + .get_mapped_range_mut() + .map_err(|e| format!("{e}"))? + .copy_from_slice(data); + buf.unmap(); + Ok(buf) + } + + /// Creates a buffer; an out-of-memory or validation failure becomes an error. + fn create_checked( + &self, + size: u64, + usage: wgpu::BufferUsages, + label: &str, + mapped: bool, ) -> Result { let oom = self .g @@ -574,7 +603,7 @@ impl Bench { label: Some(label), size, usage, - mapped_at_creation: false, + mapped_at_creation: mapped, }); let v = pollster::block_on(val.pop()); let o = pollster::block_on(oom.pop()); @@ -948,14 +977,11 @@ impl Bench { n: u32, row_off: u32, ) -> Result { - let dims = self.try_buffer( - 16, - wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + let dims = self.try_buffer_with( + bytemuck::cast_slice(&[n, row_off, 0, 0]), + wgpu::BufferUsages::UNIFORM, "dims", )?; - self.g - .queue - .write_buffer(&dims, 0, bytemuck::cast_slice(&[n, row_off, 0, 0])); Ok(self.bind(k, &[bufs.0, bufs.1, bufs.2, &dims])) } From b4678c379813763d6c5a532cfb3818ac0fdd7c72 Mon Sep 17 00:00:00 2001 From: dafioram Date: Wed, 4 Nov 2026 14:23:35 +0000 Subject: [PATCH 07/12] Retry a failed matmul check as one dispatch over all rows The WARP run still wrote only the bottom 64-row band after the uniform change, and the log does not show which build produced it. To separate per-band dispatches from the kernel and copy path, a failed FP32 check now runs the whole matmul once as a single dispatch with a freshly made row offset of 0, then checks it again and prints a tile map. Failure-only and diagnostic; the reported result is still the first check. The matmul keeps its A and B buffers so the retry can bind them. If the retry passes on WARP, the per-band dispatches are the fault; if it fails the same way, the kernel or the copy back is. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- src/bench.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/bench.rs b/src/bench.rs index 07483d1..456569a 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -375,6 +375,8 @@ struct Matmul { kernel: Rc, /// One bind group per tile-row offset (its own small uniform buffer). binds: Vec, + a: wgpu::Buffer, + b: wgpu::Buffer, c: wgpu::Buffer, a_host: Vec, b_host: Vec, @@ -1041,6 +1043,8 @@ impl Bench { Ok(Matmul { kernel: k, binds, + a, + b, c, a_host: ha, b_host: hb, @@ -1118,6 +1122,35 @@ impl Bench { self.wait(); } + /// Diagnostic for a failed check: runs the whole matmul as one dispatch + /// with a freshly made row offset of 0, then checks it again. If that + /// passes, the separate per-band dispatches are at fault; if it fails the + /// same way, the kernel or the copy back is. + fn single_dispatch_retry(&self, m: &Matmul, f16: bool) { + self.log(" retry as one dispatch over all tile rows, with a fresh row offset of 0"); + let tiles = m.n / 64; + let bind = match self.dims_bind(&m.kernel, (&m.a, &m.b, &m.c), m.n, 0) { + Ok(b) => b, + Err(e) => { + self.log(format!(" retry skipped: {e}")); + return; + } + }; + let mut enc = self.g.device.create_command_encoder(&Default::default()); + { + let mut pass = enc.begin_compute_pass(&Default::default()); + pass.set_pipeline(&m.kernel.pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.dispatch_workgroups(tiles, tiles, 1); + } + self.g.queue.submit([enc.finish()]); + self.wait(); + match self.matmul_check(&m.c, &m.a_host, &m.b_host, m.n, f16) { + Ok((bad, _)) => self.log(format!(" retry check: {bad}/256 bad")), + Err(e) => self.log(format!(" retry check failed: {e}")), + } + } + /// Compares 256 sampled entries of C against a CPU dot product. fn matmul_check( &self, @@ -1199,6 +1232,9 @@ impl Bench { let (best, avg) = summarize(×); let flops = 2.0 * (n as f64).powi(3) * reps as f64; let (bad, max_err) = self.matmul_check(&m.c, &m.a_host, &m.b_host, n, f16)?; + if bad > 0 && !self.stopped() { + self.single_dispatch_retry(&m, f16); + } Ok(TestResult { test, value: flops / avg / 1e9, From a8551d9d814ab7961138b2c5f64734515bed7a25 Mon Sep 17 00:00:00 2001 From: dafioram Date: Wed, 4 Nov 2026 14:41:38 +0000 Subject: [PATCH 08/12] Use one matmul dispatch over all rows on software adapters On the WARP device, one dispatch per 64-row band wrote only the last band; the other 31 bands stayed zero. A retry run as one dispatch over all rows checked 0 of 256 bad. Writing the band uniforms at creation did not change the result, so the fault is in the per-band dispatches themselves, not the kernel or the copy back. The root cause is not identified. Software adapters now plan one dispatch over all tile rows. Real GPUs keep the watchdog-sized bands. The WARP and llvmpipe paths are not yet confirmed on Windows; the llvmpipe run passes here. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- src/bench.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/bench.rs b/src/bench.rs index 456569a..0bfd6e1 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -409,6 +409,18 @@ fn plan_band(row_secs: f64, tiles: u32) -> (u32, usize) { (band, per_submit) } +/// Band size and dispatches per submit. On a software adapter (the WARP device) +/// one dispatch covers all tile rows: with one dispatch per band, WARP wrote +/// only the last band, while a single dispatch over all rows came out exact. +/// Real GPUs keep the watchdog-sized bands. +fn matmul_plan(row_secs: f64, tiles: u32, software: bool) -> (u32, usize) { + if software { + (tiles, 1) + } else { + plan_band(row_secs, tiles) + } +} + /// Throughput of the fastest pass, in GB/s: `bytes` moved in the shortest time. fn best_pass_gbs(bytes: u64, pass_secs: &[f64]) -> f64 { let fastest = pass_secs.iter().cloned().fold(f64::INFINITY, f64::min); @@ -1085,10 +1097,12 @@ impl Bench { fn refine_plan(&self, m: &mut Matmul) { let tiles = m.n / 64; let secs = timed(|| self.run_matmul(m, 1)); - let (band, per_submit) = plan_band(secs / tiles as f64, tiles); + let software = self.g.info.device_type == wgpu::DeviceType::Cpu; + let (band, per_submit) = matmul_plan(secs / tiles as f64, tiles, software); self.log(format!( - "Matmul plan from a measured {:.2} ms per tile row: {band} tile rows per dispatch, {per_submit} dispatches per submit", - secs / tiles as f64 * 1e3 + "Matmul plan from a measured {:.2} ms per tile row: {band} tile rows per dispatch, {per_submit} dispatches per submit{}", + secs / tiles as f64 * 1e3, + if software { " (software adapter)" } else { "" } )); m.band = band; m.per_submit = per_submit; @@ -1721,6 +1735,12 @@ mod tests { assert_eq!(plan_band(1.0, 32), (1, 1)); } + #[test] + fn software_adapters_get_one_dispatch_over_all_rows() { + assert_eq!(matmul_plan(1.0, 32, true), (32, 1)); + assert_eq!(matmul_plan(0.0002, 32, false), plan_band(0.0002, 32)); + } + #[test] fn best_pass_is_the_fastest_pass() { // 1 GB in 0.5 s and then 0.25 s: the fastest pass is 4 GB/s. From 28830f18f1739dab1e75decbb5a9bb6eae4e3f0f Mon Sep 17 00:00:00 2001 From: dafioram Date: Wed, 4 Nov 2026 14:55:01 +0000 Subject: [PATCH 09/12] Make each matmul band's bind group just before its dispatch The WARP run with one dispatch over all rows still wrote only the bottom tile row, even though the plan now covers all rows in one dispatch. A retry that makes a fresh bind group just before a single dispatch checked 0 of 256 bad, so the bind groups made at setup are the fault. Their row offsets read the last band's value on WARP. Each band now gets its bind group right before its dispatch, the same as the passing retry. The setup-time bind groups are gone. Locally, the llvmpipe matmul passes both with the software plan and with a forced multi-band plan (32 bind groups), so the new path is exercised. Not yet confirmed on WARP or on a real GPU's bands. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- src/bench.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/bench.rs b/src/bench.rs index 0bfd6e1..ba2f71f 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -373,8 +373,6 @@ enum Bind { /// Matmul buffers plus the host inputs kept for the CPU cross-check. struct Matmul { kernel: Rc, - /// One bind group per tile-row offset (its own small uniform buffer). - binds: Vec, a: wgpu::Buffer, b: wgpu::Buffer, c: wgpu::Buffer, @@ -1044,9 +1042,6 @@ impl Bench { // --- real buffers let (a, b, c, ha, hb) = self.matmul_buffers(n, f16)?; - let binds = (0..tiles) - .map(|off| self.dims_bind(&k, (&a, &b, &c), n, off)) - .collect::, _>>()?; self.log(format!( "Matmul N={n}: probe {} GFLOPS, {band} tile rows per dispatch, {per_submit} dispatches per submit (setup {:.1} s)", num(rate / 1e9), @@ -1054,7 +1049,6 @@ impl Bench { )); Ok(Matmul { kernel: k, - binds, a, b, c, @@ -1122,13 +1116,23 @@ impl Bench { } } for chunk in bands.chunks(m.per_submit) { + // Each band's bind group is made just before its dispatch. Ones made + // at setup all read the last band's row offset on WARP, while a bind + // group made right before a single dispatch came out exact. + let mut planned = Vec::with_capacity(chunk.len()); + for &(off, rows) in chunk { + match self.dims_bind(&m.kernel, (&m.a, &m.b, &m.c), m.n, off) { + Ok(bind) => planned.push((bind, rows)), + Err(e) => self.log(format!(" band at tile row {off} not run: {e}")), + } + } let mut enc = self.g.device.create_command_encoder(&Default::default()); { let mut pass = enc.begin_compute_pass(&Default::default()); pass.set_pipeline(&m.kernel.pipeline); - for &(off, rows) in chunk { - pass.set_bind_group(0, &m.binds[off as usize], &[]); - pass.dispatch_workgroups(tiles, rows, 1); + for (bind, rows) in &planned { + pass.set_bind_group(0, bind, &[]); + pass.dispatch_workgroups(tiles, *rows, 1); } } self.g.queue.submit([enc.finish()]); From 3b2d11c65571e5682cd8076e136bb993ccd6d14b Mon Sep 17 00:00:00 2001 From: dafioram Date: Wed, 4 Nov 2026 15:05:00 +0000 Subject: [PATCH 10/12] Run the WARP CI matmul at N=2048 and ship README with the builds The WARP smoke step ran matmul at N=256, which passed on every commit and so never reached the N=2048 failure that the software adapter hit. It now runs at N=2048 so CI catches a broken multi-band matmul on the software device. Build artifacts for Linux and Windows now include README.md alongside the binaries. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94bd206..eefa5e9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,7 +69,7 @@ jobs: run: | & .\target\release\gpu-workbench-cli.exe --list if ($LASTEXITCODE -ne 0) { throw "--list failed with exit code $LASTEXITCODE" } - & .\target\release\gpu-workbench-cli.exe --cli --tests upload,readback,copy,bandwidth,matmul32,vram --transfer-mb 64 --matmul-n 256 --vram-mb 128 --vram-passes 1 + & .\target\release\gpu-workbench-cli.exe --cli --tests upload,readback,copy,bandwidth,matmul32,vram --transfer-mb 64 --matmul-n 2048 --vram-mb 128 --vram-passes 1 if ($LASTEXITCODE -ne 0) { throw "CLI smoke test failed with exit code $LASTEXITCODE" } # Refusing bad input must also exit non-zero from PowerShell. - name: Refused options exit 2 (Windows) @@ -93,3 +93,4 @@ jobs: path: | ${{ matrix.gui }} ${{ matrix.cli }} + README.md From 145befb44ecc9b615ffafda8f66973f39e68ae82 Mon Sep 17 00:00:00 2001 From: dafioram Date: Thu, 5 Nov 2026 01:39:23 +0000 Subject: [PATCH 11/12] Make the WARP CI gate fail unless the matmul ran at N=2048 The planner shrinks N when its probe is slow, and the smaller run still prints PASS, so the N=2048 step could pass without testing N=2048. The control run did exactly that (N=1024). The step now fails if the output contains "auto-reduced", or if no PASS line names N=2048 for the matmul. The CLI's exit code is still checked first. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- .github/workflows/build.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eefa5e9..3770891 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,8 +69,15 @@ jobs: run: | & .\target\release\gpu-workbench-cli.exe --list if ($LASTEXITCODE -ne 0) { throw "--list failed with exit code $LASTEXITCODE" } - & .\target\release\gpu-workbench-cli.exe --cli --tests upload,readback,copy,bandwidth,matmul32,vram --transfer-mb 64 --matmul-n 2048 --vram-mb 128 --vram-passes 1 - if ($LASTEXITCODE -ne 0) { throw "CLI smoke test failed with exit code $LASTEXITCODE" } + $out = & .\target\release\gpu-workbench-cli.exe --cli --tests upload,readback,copy,bandwidth,matmul32,vram --transfer-mb 64 --matmul-n 2048 --vram-mb 128 --vram-passes 1 2>&1 + $exit = $LASTEXITCODE + $out | ForEach-Object { "$_" } + if ($exit -ne 0) { throw "CLI smoke test failed with exit code $exit" } + # The planner shrinks N when its probe is slow, and the smaller run still + # prints PASS. The gate is only meaningful at N=2048, so check for that. + $text = $out | Out-String + if ($text -match 'auto-reduced') { throw "matmul was auto-reduced from N=2048; the gate did not test N=2048" } + if ($text -notmatch '\[PASS\][^\r\n]*N=2048,') { throw "no PASS line for the matmul at N=2048" } # Refusing bad input must also exit non-zero from PowerShell. - name: Refused options exit 2 (Windows) if: runner.os == 'Windows' From d4cf9246867aee4577c53eadb20a92b6ebfca9c6 Mon Sep 17 00:00:00 2001 From: dafioram Date: Thu, 5 Nov 2026 02:08:43 +0000 Subject: [PATCH 12/12] Keep the requested matmul size on software adapters The planner shrinks N when its probe says a band would take over 0.2 s, to avoid GPU watchdog resets. On the CI runner's WARP the probe measured 2.30 GFLOPS, which is below the 2.68 needed to keep N=2048, so the run became N=1024 and the new gate failed the step. A software adapter has no such watchdog. The 32-row single dispatch ran at N=2048 on the 10.0.19041 WARP without a device loss, so the shrink is skipped for CPU devices. GPUs keep it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DS52oJk4dJpQPT7u2QpTr8 --- src/bench.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bench.rs b/src/bench.rs index ba2f71f..6fcd09b 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -1026,7 +1026,11 @@ impl Bench { // --- plan let band_time = |n: u32| 2.0 * (n as f64) * (n as f64) * 64.0 / rate; let requested = n; - while band_time(n) > MATMUL_BAND_LIMIT && n > PN { + // The shrink guards against GPU watchdog resets. A software adapter has + // no such watchdog and keeps the size asked for, so a slow probe on a + // CPU device cannot turn a 2048 run into a 1024 one. + let software = self.g.info.device_type == wgpu::DeviceType::Cpu; + while !software && band_time(n) > MATMUL_BAND_LIMIT && n > PN { n = (n / 2 / 64 * 64).max(PN); } let mut note = String::new();