From c0473cf377e3bc680a02d5a6e2e80f2f7cd62f8d Mon Sep 17 00:00:00 2001 From: sdcb Date: Mon, 28 Sep 2026 22:07:11 +0800 Subject: [PATCH 01/12] vulkan: load libvulkan.so on Android Android exposes the Vulkan loader only under the unversioned NDK name (/system/lib64/libvulkan.so); the resolver tried libvulkan.so.1 alone, so VkDevice.Create failed and every session fell back to the CPU. It now tries libvulkan.so.1, then libvulkan.so, in the one existing resolver, and a miss names every file it tried instead of the P/Invoke's "vulkan-1". Realme GT5 Pro (Snapdragon 8 Gen 3, Android 16): the loader resolves to libvulkan.so and VkDevice.Create picks "Adreno (TM) 750", Vulkan 1.3.128. Windows / desktop Linux resolve the same file names as before. --- src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs index 83da462..3e90b71 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs @@ -1,5 +1,6 @@ // Minimal Vulkan 1.1 P/Invoke for compute-only use (SimdPaddleOCR Vulkan backend). -// Loads vulkan-1.dll (Windows) / libvulkan.so.1 (Linux) via SetDllImportResolver. +// Loads vulkan-1.dll (Windows) / libvulkan.so.1 (Linux) / libvulkan.so (Android) +// via SetDllImportResolver. // Only the entry points and structs we need; all structs are blittable. using System.Runtime.InteropServices; @@ -11,6 +12,10 @@ internal static unsafe partial class Vk private static int _resolverRegistered; + // Android ships only the unversioned NDK name (/system/lib64/libvulkan.so) + private static readonly string[] s_candidates = OperatingSystem.IsWindows() + ? ["vulkan-1.dll"] : ["libvulkan.so.1", "libvulkan.so"]; + public static void RegisterResolver() { if (Interlocked.Exchange(ref _resolverRegistered, 1) != 0) @@ -19,11 +24,20 @@ public static void RegisterResolver() { if (name != LibName) return IntPtr.Zero; - string candidate = OperatingSystem.IsWindows() ? "vulkan-1.dll" : "libvulkan.so.1"; - return NativeLibrary.TryLoad(candidate, out IntPtr h) ? h : IntPtr.Zero; + foreach (string candidate in s_candidates) + if (NativeLibrary.TryLoad(candidate, out IntPtr h)) + { + LoadedLibrary = candidate; + return h; + } + throw new DllNotFoundException( + $"Vulkan loader not found (tried {string.Join(", ", s_candidates)})"); }); } + /// File name the resolver loaded, or null before the first call. + public static string? LoadedLibrary { get; private set; } + public const ulong WholeSize = ~0UL; public const uint QueueFamilyIgnored = ~0u; From 98da6e23c72f9d05549c89941dc839850cbe19e8 Mon Sep 17 00:00:00 2001 From: sdcb Date: Mon, 28 Sep 2026 22:09:28 +0800 Subject: [PATCH 02/12] test: add an Android bench host for the Vulkan backend test/Sdcb.SimdPaddleOCR.AndroidBench is a net10.0-android app (CoreCLR, references the library's net10.0 build: AdvSimd kernels + Vulkan) driven over adb by run.ps1: build, install, push bench-out/models and dataset/ into the app's external files dir, launch with args/env extras, wait for .done, pull files/out into bench-out/android and keep logcat when the process dies. Models are read from files, nothing is packaged. Modes: --caps (device, sgRange, gl_SubgroupSize under required size 0/16/32/64/128 through VkDevice.NewPipeline, coopmat list, limits, heaps, queues, timestamp check), --e2e (the Tests harness's options and JSON rows, so cmp.ps1 / steady.ps1 / --summarize work), --detprof / --recprof, --detcmp / --reccmp (CPU vs Vulkan on the same input), --conc, and two bisection aids: --layers (truncate the GPU graph after node k and read that node's output back) and --gemm (one GEMM dispatch vs an fp64 reference). GpuBench links the layer probe, GEMM check and CPU/GPU compare so the same numbers can be taken on a desktop GPU. Not in the .slnx or CI: it needs the android workload. --- .../Properties/InternalsVisibleTo.cs | 1 + test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs | 47 ++++ test/Sdcb.SimdPaddleOCR.AndroidBench/Caps.cs | 228 ++++++++++++++++++ .../Compare.cs | 98 ++++++++ test/Sdcb.SimdPaddleOCR.AndroidBench/E2e.cs | 168 +++++++++++++ .../GemmTest.cs | 136 +++++++++++ .../LayerProbe.cs | 53 ++++ .../MainActivity.cs | 118 +++++++++ test/Sdcb.SimdPaddleOCR.AndroidBench/Paths.cs | 42 ++++ test/Sdcb.SimdPaddleOCR.AndroidBench/Prof.cs | 80 ++++++ .../Sdcb.SimdPaddleOCR.AndroidBench.csproj | 40 +++ .../global.json | 6 + test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 | 92 +++++++ test/Sdcb.SimdPaddleOCR.GpuBench/Harness.cs | 17 ++ test/Sdcb.SimdPaddleOCR.GpuBench/Program.cs | 6 + .../Sdcb.SimdPaddleOCR.GpuBench.csproj | 4 + 16 files changed, 1136 insertions(+) create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/Caps.cs create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/Compare.cs create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/E2e.cs create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/GemmTest.cs create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/LayerProbe.cs create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/MainActivity.cs create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/Paths.cs create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/Prof.cs create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/Sdcb.SimdPaddleOCR.AndroidBench.csproj create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/global.json create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 diff --git a/src/Sdcb.SimdPaddleOCR/Properties/InternalsVisibleTo.cs b/src/Sdcb.SimdPaddleOCR/Properties/InternalsVisibleTo.cs index 6e3d50d..7dfcd06 100644 --- a/src/Sdcb.SimdPaddleOCR/Properties/InternalsVisibleTo.cs +++ b/src/Sdcb.SimdPaddleOCR/Properties/InternalsVisibleTo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("Sdcb.SimdPaddleOCR.Tests")] [assembly: InternalsVisibleTo("Sdcb.SimdPaddleOCR.UnitTests")] [assembly: InternalsVisibleTo("Sdcb.SimdPaddleOCR.GpuBench")] +[assembly: InternalsVisibleTo("Sdcb.SimdPaddleOCR.AndroidBench")] diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs new file mode 100644 index 0000000..c6b485d --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs @@ -0,0 +1,47 @@ +namespace Sdcb.SimdPaddleOCR.AndroidBench; + +/// Mode dispatch. Relative paths resolve against the app's external files dir. +static class Bench +{ + internal static string OutDir = ""; + + internal static int Run(string[] args, string root, string outDir) + { + Paths.Root = root; + OutDir = outDir; + if (args.Length == 0) return Usage(); + return args[0] switch + { + "--init" => 0, + "--caps" => Caps.Run(args), + "--e2e" => E2e.Run(args), + "--detprof" or "--recprof" => Prof.Run(args), + "--detcmp" or "--reccmp" => Compare.Run(args), + "--conc" => Prof.Conc(args), + "--rawbench" => Caps.RawBench(args), + "--gemm" => GemmTest.Run(args), + // --layers [first] [last] + "--layers" => LayerProbe.Run(Paths.ModelPath(args[1], args[2]), int.Parse(args[3]), int.Parse(args[4]), + int.Parse(args[5]), args.Length > 6 ? int.Parse(args[6]) : 0, + args.Length > 7 ? int.Parse(args[7]) : int.MaxValue), + _ => Usage(), + }; + } + + static int Usage() + { + Console.WriteLine(""" + --caps + --e2e --model tiny|small|medium --backend cpu|vulkan|auto [--workers 4] [--count N] [--warmup N] [--out name.json] + --detprof [reps] SIMD_OCR_GPU_PROF=1 for per-kernel times + --recprof [reps] + --detcmp [maxSide] CPU vs Vulkan prob map + boxes on the same input + --reccmp CPU vs Vulkan REC activations + CTC argmax + --conc [threads] [backend] + --gemm [flags] [tileM] [tileN] [reqSg] [reps] + --layers [first] [last] + --rawbench [pc uints...] + """); + return 2; + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Caps.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/Caps.cs new file mode 100644 index 0000000..1f31e10 --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/Caps.cs @@ -0,0 +1,228 @@ +using System.Runtime.InteropServices; +using Sdcb.SimdPaddleOCR.Backends.Vulkan; + +namespace Sdcb.SimdPaddleOCR.AndroidBench; + +/// Device capability probe (M0) and single-dispatch timing. +static unsafe class Caps +{ + static string CompType(uint t) => t switch + { + 0 => "f16", 1 => "f32", 2 => "f64", 3 => "s8", 4 => "s16", 5 => "s32", 6 => "s64", + 7 => "u8", 8 => "u16", 9 => "u32", 10 => "u64", 1000141000 => "bf16", + _ => t.ToString(), + }; + + static string Scope(uint s) => s switch { 1 => "device", 2 => "workgroup", 3 => "subgroup", 5 => "queuefamily", _ => s.ToString() }; + + internal static int Run(string[] args) + { + Console.WriteLine($"phone: {Android.OS.Build.Manufacturer} {Android.OS.Build.Model} ({Android.OS.Build.Device}) " + + $"Android {Android.OS.Build.VERSION.Release} API {(int)Android.OS.Build.VERSION.SdkInt} soc={(OperatingSystem.IsAndroidVersionAtLeast(31) ? Android.OS.Build.SocModel : "?")}"); + Console.WriteLine($"runtime: {RuntimeInformation.FrameworkDescription} rid={RuntimeInformation.RuntimeIdentifier} " + + $"cpus={Environment.ProcessorCount} advsimd={System.Runtime.Intrinsics.Arm.AdvSimd.IsSupported} " + + $"dotprod={System.Runtime.Intrinsics.Arm.Dp.IsSupported} vector={System.Numerics.Vector.Count}x{System.Numerics.Vector.IsHardwareAccelerated} " + + $"lib={typeof(PaddleOcrAll).Assembly.GetCustomAttributes(typeof(System.Runtime.Versioning.TargetFrameworkAttribute), false).OfType().FirstOrDefault()?.FrameworkName}"); + + using var dev = VkDevice.Create(); + Console.WriteLine($"vulkan loader: {Vk.LoadedLibrary}"); + Vk.VkPhysicalDeviceProperties props; + Vk.vkGetPhysicalDeviceProperties(dev.PhysDevice, &props); + uint api = props.ApiVersion; + Console.WriteLine($"device: {dev.DeviceName} vendor=0x{props.VendorID:x} deviceId=0x{props.DeviceID:x} type={props.DeviceType} " + + $"api={api >> 22 & 0x7f}.{api >> 12 & 0x3ff}.{api & 0xfff} driver=0x{props.DriverVersion:x8}"); + + byte* lim = props.LimitsAndSparse + 4; // native VkPhysicalDeviceLimits is 8-aligned + Console.WriteLine($"limits: maxComputeSharedMemorySize={*(uint*)(lim + 216)} maxWgInvocations={*(uint*)(lim + 232)} " + + $"maxWgSize={*(uint*)(lim + 236)}x{*(uint*)(lim + 240)}x{*(uint*)(lim + 244)} " + + $"maxWgCount={*(uint*)(lim + 220)}x{*(uint*)(lim + 224)}x{*(uint*)(lim + 228)}"); + Console.WriteLine($"limits: maxStorageBufferRange={*(uint*)(lim + 28)} minStorageBufferOffsetAlignment={*(ulong*)(lim + 328)} " + + $"maxPushConstantsSize={*(uint*)(lim + 32)} maxMemoryAllocationCount={*(uint*)(lim + 36)} timestampPeriod={*(float*)(lim + 424)}ns"); + + uint nqf = 0; + Vk.vkGetPhysicalDeviceQueueFamilyProperties(dev.PhysDevice, &nqf, null); + var qf = stackalloc Vk.VkQueueFamilyProperties[(int)nqf]; + Vk.vkGetPhysicalDeviceQueueFamilyProperties(dev.PhysDevice, &nqf, qf); + for (uint i = 0; i < nqf; i++) + Console.WriteLine($"queue family {i}: flags=0x{qf[i].QueueFlags:x} count={qf[i].QueueCount} timestampValidBits={qf[i].TimestampValidBits}{(i == dev.QueueFamily ? " <- selected" : "")}"); + Console.WriteLine($"queue globalPriority={dev.QueuePriority}"); + + Console.WriteLine($"subgroup: default={dev.SubgroupSize} stages=0x{dev.SubgroupStages:x} ops=0x{dev.SubgroupOps:x} " + + $"sizeControl={dev.SubgroupSizeControl} sgRange={dev.SubgroupMin}-{dev.SubgroupMax} requiredStagesCompute={dev.ComputeSubgroupSize}"); + Console.WriteLine($"features: storage16={dev.Storage16Bit} shaderFloat16={dev.ShaderFloat16} coopMatrix={dev.CoopMatrix} " + + $"push={dev.PushDescriptors} coop16x16x16={dev.Coop16x16x16} coop8x16x16={dev.Coop8x16x16} best={dev.CoopM}x{dev.CoopN}x{dev.CoopK}"); + Console.WriteLine($"routing: Sg32Subgroup={dev.Sg32Subgroup} CoopGemm={dev.CoopGemm}"); + + for (int i = 0; i < (int)dev.MemProps.MemoryHeapCount; i++) + Console.WriteLine($"heap {i}: {dev.MemProps.HeapAt(i).Size >> 20} MB flags=0x{dev.MemProps.HeapAt(i).Flags:x}"); + for (int i = 0; i < (int)dev.MemProps.MemoryTypeCount; i++) + Console.WriteLine($"memtype {i}: flags=0x{dev.MemProps.TypeAt(i).PropertyFlags:x} heap={dev.MemProps.TypeAt(i).HeapIndex}"); + + uint next = 0; + Vk.vkEnumerateDeviceExtensionProperties(dev.PhysDevice, null, &next, null); + var exts = new Vk.VkExtensionProperties[next]; + fixed (Vk.VkExtensionProperties* pe = exts) + Vk.vkEnumerateDeviceExtensionProperties(dev.PhysDevice, null, &next, pe); + var names = new List(); + for (int i = 0; i < next; i++) + fixed (byte* n = exts[i].ExtensionName) names.Add(new string((sbyte*)n)); + Console.WriteLine($"extensions ({names.Count}): {string.Join(' ', names.OrderBy(x => x))}"); + + CoopList(dev); + SubgroupProbe(dev); + TimestampProbe(dev); + return 0; + } + + static void CoopList(VkDevice dev) + { + byte[] fn = System.Text.Encoding.ASCII.GetBytes("vkGetPhysicalDeviceCooperativeMatrixPropertiesKHR\0"); + IntPtr p; + fixed (byte* f = fn) p = Vk.vkGetInstanceProcAddr(dev.Instance, f); + if (p == IntPtr.Zero) { Console.WriteLine("coopmat: vkGetPhysicalDeviceCooperativeMatrixPropertiesKHR not exported"); return; } + var q = (delegate* unmanaged[Cdecl])p; + uint np = 0; + q(dev.PhysDevice, &np, null); + var cm = new Vk.VkCooperativeMatrixPropertiesKHR[np]; + for (int i = 0; i < np; i++) cm[i].SType = VkConst.StCooperativeMatrixPropertiesKHR; + fixed (Vk.VkCooperativeMatrixPropertiesKHR* pc = cm) q(dev.PhysDevice, &np, pc); + Console.WriteLine($"coopmat: {np} configurations"); + foreach (var c in cm) + Console.WriteLine($" coopmat {c.MSize}x{c.NSize}x{c.KSize} A={CompType(c.AType)} B={CompType(c.BType)} C={CompType(c.CType)} R={CompType(c.ResultType)} sat={c.SaturatingAccumulation} scope={Scope(c.Scope)}"); + } + + static byte[] Resource(string name) + { + using Stream s = typeof(Caps).Assembly.GetManifestResourceStream(name)!; + byte[] b = new byte[s.Length]; + s.ReadExactly(b); + return b; + } + + /// gl_SubgroupSize as seen by a pipeline built through the same + /// VkDevice.NewPipeline path the graph uses, per requiredSubgroupSize. + static void SubgroupProbe(VkDevice dev) + { + byte[] spv = Resource("sgsize.spv"); + foreach (uint req in new uint[] { 0, 16, 32, 64, 128 }) + { + if (req != 0 && !(dev.SubgroupSizeControl && dev.ComputeSubgroupSize && req >= dev.SubgroupMin && req <= dev.SubgroupMax)) + { + Console.WriteLine($"requiredSubgroupSize={req}: not requested (outside sgRange {dev.SubgroupMin}-{dev.SubgroupMax} or no compute size control; invalid usage)"); + continue; + } + try + { + var sp = dev.NewPipeline(dev.NewShaderModule(spv), 1, 4, req); + var set = dev.NewDescriptorSet(sp.SetLayout); + const int groups = 64; + var ob = dev.NewStorageBuffer(groups * 16, hostVisible: true, preferHost: true); + dev.BindBuffer(set, 0, ob); + IntPtr cmd = dev.NewCommandBuffer(); + IntPtr f = dev.NewFence(); + var begin = new Vk.VkCommandBufferBeginInfo { SType = VkConst.StCommandBufferBeginInfo }; + Vk.Check(Vk.vkBeginCommandBuffer(cmd, &begin), "begin"); + Vk.vkCmdBindPipeline(cmd, VkConst.BindPointCompute, sp.Pipeline); + Vk.vkCmdBindDescriptorSets(cmd, VkConst.BindPointCompute, sp.Layout, 0, 1, &set, 0, null); + Vk.vkCmdDispatch(cmd, groups, 1, 1); + Vk.Check(Vk.vkEndCommandBuffer(cmd), "end"); + dev.Submit(cmd, f); dev.WaitFence(f); + uint* o = (uint*)ob.Map(); + var sizes = new HashSet(); + for (int g = 0; g < groups; g++) sizes.Add($"gl_SubgroupSize={o[g * 4]} numSubgroups={o[g * 4 + 1]} lastSubgroupId={o[g * 4 + 2]} lastLane={o[g * 4 + 3]}"); + ob.Unmap(); + Console.WriteLine($"requiredSubgroupSize={req}: {string.Join(" | ", sizes)}"); + dev.DestroyFence(f); + ob.Free(); + } + catch (Exception ex) + { + Console.WriteLine($"requiredSubgroupSize={req}: FAILED {ex.Message}"); + } + } + } + + static void TimestampProbe(VkDevice dev) + { + byte[] spv = Resource("sgsize.spv"); + var sp = dev.NewPipeline(dev.NewShaderModule(spv), 1, 4, 0); + var set = dev.NewDescriptorSet(sp.SetLayout); + var ob = dev.NewStorageBuffer(1 << 20, hostVisible: false); + dev.BindBuffer(set, 0, ob); + IntPtr cmd = dev.NewCommandBuffer(); + IntPtr f = dev.NewFence(); + var qci = new Vk.VkQueryPoolCreateInfo { SType = 11, QueryType = 2, QueryCount = 2 }; + Vk.Check(Vk.vkCreateQueryPool(dev.Device, &qci, null, out IntPtr qp), "qp"); + var begin = new Vk.VkCommandBufferBeginInfo { SType = VkConst.StCommandBufferBeginInfo }; + Vk.Check(Vk.vkBeginCommandBuffer(cmd, &begin), "begin"); + Vk.vkCmdResetQueryPool(cmd, qp, 0, 2); + Vk.vkCmdBindPipeline(cmd, VkConst.BindPointCompute, sp.Pipeline); + Vk.vkCmdBindDescriptorSets(cmd, VkConst.BindPointCompute, sp.Layout, 0, 1, &set, 0, null); + Vk.vkCmdWriteTimestamp(cmd, VkConst.PipelineStageBottomOfPipe, qp, 0); + Vk.vkCmdDispatch(cmd, 16384, 1, 1); + Vk.vkCmdWriteTimestamp(cmd, VkConst.PipelineStageBottomOfPipe, qp, 1); + Vk.Check(Vk.vkEndCommandBuffer(cmd), "end"); + long t0 = System.Diagnostics.Stopwatch.GetTimestamp(); + dev.Submit(cmd, f); dev.WaitFence(f); + double wall = System.Diagnostics.Stopwatch.GetElapsedTime(t0).TotalMilliseconds; + ulong* ts = stackalloc ulong[2]; + var r = Vk.vkGetQueryPoolResults(dev.Device, qp, 0, 2, 16, ts, 8, 1 | 2); + Console.WriteLine($"timestamp probe: result={r} t0={ts[0]} t1={ts[1]} delta={(ts[1] - ts[0]) * dev.TimestampPeriodNs / 1e6:F4} ms (wall {wall:F3} ms, 16384 WGs x 256)"); + Vk.vkDestroyQueryPool(dev.Device, qp, null); + dev.DestroyFence(f); + ob.Free(); + } + + // --rawbench [pc uints...] + internal static int RawBench(string[] args) + { + using var dev = VkDevice.Create(); + byte[] spv = File.ReadAllBytes(Paths.Path_(args[1])); + int nbind = int.Parse(args[2]); + uint gx = uint.Parse(args[3]), gy = uint.Parse(args[4]); + int reps = int.Parse(args[5]); + ulong bytes = ulong.Parse(args[6]) << 20; + uint reqSg = uint.Parse(args[7]); + uint[] pcs = args.Skip(8).Select(uint.Parse).ToArray(); + int pcBytes = Math.Max(16, pcs.Length * 4); + var rp = dev.NewPipeline(dev.NewShaderModule(spv), nbind, pcBytes, reqSg); + var set = dev.NewDescriptorSet(rp.SetLayout); + var bufs = new List(); + for (int b = 0; b < nbind; b++) + { + var buf = dev.NewStorageBuffer(bytes, hostVisible: false); + dev.Zero(buf); + dev.BindBuffer(set, (uint)b, buf); + bufs.Add(buf); + } + IntPtr cmd = dev.NewCommandBuffer(); + IntPtr f = dev.NewFence(); + var qci = new Vk.VkQueryPoolCreateInfo { SType = 11, QueryType = 2, QueryCount = 2 }; + Vk.Check(Vk.vkCreateQueryPool(dev.Device, &qci, null, out IntPtr qp), "qp"); + uint[] pcv = new uint[pcBytes / 4]; + pcs.CopyTo(pcv, 0); + double best = double.MaxValue; + ulong* ts = stackalloc ulong[2]; + for (int r = 0; r < reps; r++) + { + var begin = new Vk.VkCommandBufferBeginInfo { SType = VkConst.StCommandBufferBeginInfo }; + Vk.Check(Vk.vkResetCommandBuffer(cmd, 0), "reset"); + Vk.Check(Vk.vkBeginCommandBuffer(cmd, &begin), "begin"); + Vk.vkCmdResetQueryPool(cmd, qp, 0, 2); + Vk.vkCmdBindPipeline(cmd, VkConst.BindPointCompute, rp.Pipeline); + Vk.vkCmdBindDescriptorSets(cmd, VkConst.BindPointCompute, rp.Layout, 0, 1, &set, 0, null); + fixed (uint* pp = pcv) Vk.vkCmdPushConstants(cmd, rp.Layout, VkConst.StageComputeShader, 0, (uint)pcBytes, pp); + Vk.vkCmdWriteTimestamp(cmd, VkConst.PipelineStageBottomOfPipe, qp, 0); + Vk.vkCmdDispatch(cmd, gx, gy, 1); + Vk.vkCmdWriteTimestamp(cmd, VkConst.PipelineStageBottomOfPipe, qp, 1); + Vk.Check(Vk.vkEndCommandBuffer(cmd), "end"); + dev.Submit(cmd, f); dev.WaitFence(f); + Vk.vkGetQueryPoolResults(dev.Device, qp, 0, 2, 16, ts, 8, 1 | 2); + best = Math.Min(best, (ts[1] - ts[0]) * dev.TimestampPeriodNs / 1e6); + } + Console.WriteLine($"rawbench best={best:F4} ms"); + Vk.vkDestroyQueryPool(dev.Device, qp, null); + foreach (var b in bufs) b.Free(); + return 0; + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Compare.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/Compare.cs new file mode 100644 index 0000000..553bcce --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/Compare.cs @@ -0,0 +1,98 @@ +using Sdcb.SimdPaddleOCR.Backends.Vulkan; +using Sdcb.SimdPaddleOCR.OnnxSharp; + +namespace Sdcb.SimdPaddleOCR.AndroidBench; + +/// Same input through the CPU and Vulkan paths on the phone. +static class Compare +{ + internal static int Run(string[] args) => args[0] == "--detcmp" ? Det(args) : Rec(args); + + // --detcmp [maxSide] + static int Det(string[] args) + { + string model = args[1]; + int maxSide = args.Length > 3 ? int.Parse(args[3]) : 960; + string[] files = args[2] == "all" ? Paths.DatasetFiles() + : int.TryParse(args[2], out int n) ? Paths.DatasetFiles().Take(n).ToArray() + : [Paths.Path_(args[2])]; + var mdl = Model.Load(File.ReadAllBytes(Paths.ModelPath(model, "det"))); + var compiled = new CompiledModel(mdl, intraOpThreads: 4); + var opts = new PaddleOcrDetectorOptions(); + var scratch = new DbPostprocess.Workspace(); + using var sCpu = OcrSessionFactory.Create(compiled, OcrBackend.Cpu); + using var sGpu = OcrSessionFactory.Create(compiled, OcrBackend.Vulkan); + Console.WriteLine($"cpu session={sCpu.GetType().Name} gpu session={sGpu.GetType().Name}"); + int sameBoxes = 0; + foreach (string f in files) + { + var (bgr, w, h) = Paths.LoadBgr(f); + var size = PPOCRPreprocess.ComputeDetSize(w, h, maxSide); + int W = size.Width, H = size.Height; + float[][] maps = new float[2][]; + int[] nbox = new int[2]; + string[] boxStr = new string[2]; + IOcrSession[] ss = [sCpu, sGpu]; + for (int k = 0; k < 2; k++) + { + IOcrSession s = ss[k]; + s.Reshape([1, 3, H, W]); + PPOCRPreprocess.Det(bgr, w, h, w * 3, W, H, s.InputData, s.ResizeWorkspace, 1, s.InputIsNhwc, + ImagePixelFormat.Bgr24); + maps[k] = s.RunInternal(s.InputData).ToArray(); + var boxes = DbPostprocess.Run(maps[k], W, H, opts, w, h, size.WidthRatio, size.HeightRatio, scratch); + nbox[k] = boxes.Length; + boxStr[k] = string.Join(";", boxes.Select(b => $"{b.X1:F0},{b.Y1:F0},{b.X3:F0},{b.Y3:F0}")); + } + float[] pc = maps[0], pg = maps[1]; + double md = 0, sum = 0; int up = 0, dn = 0, nz = 0; + for (int i = 0; i < pc.Length; i++) + { + double d = Math.Abs(pc[i] - pg[i]); + md = Math.Max(md, d); sum += d; + if (pg[i] != 0) nz++; + if (pc[i] < 0.3f && pg[i] >= 0.3f) up++; + if (pc[i] >= 0.3f && pg[i] < 0.3f) dn++; + } + bool same = boxStr[0] == boxStr[1]; + if (same) sameBoxes++; + Console.WriteLine($"{Path.GetFileName(f)} {W}x{H}: maxAbs={md:F4} meanAbs={sum / pc.Length:E2} gpuNonZero={nz}/{pg.Length} flips(0.3) up={up} down={dn} boxes cpu={nbox[0]} gpu={nbox[1]} {(same ? "same" : "DIFF")} gpuAlive={((sGpu as GpuSession)?.GpuAlive.ToString() ?? "n/a")}"); + } + Console.WriteLine($"detcmp {model}: identical box lists {sameBoxes}/{files.Length}"); + return 0; + } + + // --reccmp : random input, activations before the CTC projection + static int Rec(string[] args) + { + string model = args[1]; + int nb = int.Parse(args[2]), W = int.Parse(args[3]); + var mdl = Model.Load(File.ReadAllBytes(Paths.ModelPath(model, "rec"))); + var compiled = new CompiledModel(mdl, intraOpThreads: 4); + int[] shape = [nb, 3, 48, W]; + var inp = new float[nb * 3 * 48 * W]; + var rr = new Random(1234); + for (int i = 0; i < inp.Length; i++) inp[i] = (float)(rr.NextDouble() * 2 - 1); + var cpu = new InferenceSession(compiled); + cpu.Reshape(shape); + if (!cpu.TryRunUntilCtcProjection(inp, out CtcProjectionOperands ops)) + { Console.WriteLine("cpu: no CTC projection tail"); return 2; } + float[] a = ops.Activations.ToArray(); + int mm = ops.MatMulNodeIndex; + using var dev = VkDevice.Create(); + using var gg = new GpuDetGraph(dev, compiled); + float[] g = gg.Run(shape, inp, mm, checked((int)mdl.Nodes[mm].Inputs[0])).ToArray(); + double md = 0; int nz = 0; + for (int i = 0; i < a.Length; i++) { md = Math.Max(md, Math.Abs(a[i] - g[i])); if (g[i] != 0) nz++; } + int rows = ops.RowCount; + int[] ic = new int[rows], ig = new int[rows]; + float[] sc = new float[rows], sg = new float[rows]; + Kernels.MatMul.TryArgMax(a, ops.Weights, ops.Bias, ic, sc, ops.Batch, ops.Rows, ops.Inner, ops.Columns, ops.PackedWeights, 4); + Kernels.MatMul.TryArgMax(g, ops.Weights, ops.Bias, ig, sg, ops.Batch, ops.Rows, ops.Inner, ops.Columns, ops.PackedWeights, 4); + int bad = 0, badText = 0; + for (int r = 0; r < rows; r++) + if (ic[r] != ig[r]) { bad++; if (ic[r] != 0) badText++; } + Console.WriteLine($"reccmp {model} {nb}x{W}: act n={a.Length} maxAbs={md:F4} gpuNonZero={nz}/{g.Length} argmaxDiff={bad}/{rows} (non-blank {badText})"); + return 0; + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/E2e.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/E2e.cs new file mode 100644 index 0000000..04b6b05 --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/E2e.cs @@ -0,0 +1,168 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using Sdcb.SimdPaddleOCR.OnnxSharp; +using Sdcb.SimdPaddleOCR.Tests; + +namespace Sdcb.SimdPaddleOCR.AndroidBench; + +/// +/// End-to-end dataset run with the desktop Tests harness's options and JSON +/// layout (rows: file / total_ms / hash / detected / texts / stage_ms ...), +/// so cmp.ps1, steady.ps1 and Tests --summarize work on the pulled file. +/// +static class E2e +{ + internal static int Run(string[] args) + { + string model = "tiny", backendName = "cpu"; + int workers = 4, warmup = 1; + int? count = null; + string? outName = null; + for (int i = 1; i < args.Length; i++) + { + string Next() => args[++i]; + switch (args[i]) + { + case "--model": model = Next(); break; + case "--backend": backendName = Next(); break; + case "--workers": workers = int.Parse(Next()); break; + case "--count": count = int.Parse(Next()); break; + case "--warmup": warmup = int.Parse(Next()); break; + case "--out": outName = Next(); break; + default: throw new ArgumentException($"unknown argument {args[i]}"); + } + } + OcrBackend backend = Paths.ParseBackend(backendName); + string metadataPath = Path.Combine(Paths.Dataset, "metadata.json"); + string[] files = Paths.DatasetFiles(); + if (files.Length != 100) throw new InvalidOperationException($"expected 100 images, got {files.Length}"); + if (count is { } c) files = files.Take(c).ToArray(); + + var decoded = files.Select(f => (Name: Path.GetFileName(f), Img: Paths.LoadBgr(f))).ToArray(); + + const int CacheEntries = 32; + using var ocr = PaddleOcrAll.Load(Paths.ModelPath(model, "det"), Paths.ModelPath(model, "cls"), + Paths.ModelPath(model, "rec"), Paths.DictPath(model), new PaddleOcrOptions + { + LineWorkerCount = workers, + Detector = new PaddleOcrDetectorOptions { MaxSessionCacheEntries = CacheEntries, Backend = backend }, + Recognizer = new PaddleOcrRecognizerOptions { AdaptiveWidth = true, TargetWidth = 320, Backend = backend }, + Classifier = new PaddleOcrClassifierOptions { Backend = backend }, + }); + PipelineProfiler.Enable(true); + var prev = PipelineProfiler.Snapshot(); + bool gpu = Backends.Vulkan.GpuBackend.UsesGpu(backend); + double wsLoaded = WorkingSetMb(); + Console.WriteLine($"loaded model={model} backend={backendName} usesGpu={gpu} workers={ocr.EffectiveLineWorkerCount}/{workers} ws={wsLoaded:F1}MB"); + + int warmupCount = Math.Min(warmup, Math.Max(0, decoded.Length - 1)); + var rows = new List(); + var boxes = new List(); + double wsPeak = wsLoaded; + for (int index = 0; index < decoded.Length; index++) + { + var (name, (bgr, w, h)) = decoded[index]; + var sw = Stopwatch.StartNew(); + PaddleOcrResult r = ocr.Run(bgr, w, h, w * 3); + sw.Stop(); + var cur = PipelineProfiler.Snapshot(); + var stageMs = new Dictionary(); + var stageCalls = new Dictionary(); + for (int s = 0; s < PipelineProfiler.StageCount; s++) + { + stageMs[PipelineProfiler.StageNames[s]] = cur[s].Milliseconds - prev[s].Milliseconds; + stageCalls[PipelineProfiler.StageNames[s]] = cur[s].Calls - prev[s].Calls; + } + prev = cur; + double ws = WorkingSetMb(); + wsPeak = Math.Max(wsPeak, ws); + rows.Add(new BenchmarkRow + { + File = name, Width = w, Height = h, Warmup = index < warmupCount, + TotalMs = sw.Elapsed.TotalMilliseconds, StageMs = stageMs, StageCalls = stageCalls, + Detected = r.DetectedCount, Lines = r.Lines.Length, Hash = $"{r.PackedTextHash:x16}", + Texts = r.Lines.Select(x => x.Text).ToArray(), + Rotations = r.Lines.Select(x => x.AppliedRotationDegrees).ToArray(), + WorkingSetMb = ws, + }); + boxes.Add(r.Lines.Select(x => Aabb(x.Box)).ToArray()); + Console.WriteLine($"{index + 1}/{decoded.Length} {name} total={sw.Elapsed.TotalMilliseconds:F3} det={stageMs["det_graph"]:F2} lines={r.Lines.Length} ws={ws:F1}MB"); + } + + var meta = new JsonObject + { + ["mode"] = backendName == "cpu" ? "sharp" : backendName, + ["benchmark"] = true, + ["benchmarkKind"] = "simd", + ["model"] = model, + ["workers"] = workers, + ["backend"] = backendName, + ["usesGpu"] = gpu, + ["effectiveWorkers"] = ocr.EffectiveLineWorkerCount, + ["cacheEntries"] = CacheEntries, + ["rid"] = RuntimeInformation.RuntimeIdentifier, + ["os"] = RuntimeInformation.OSDescription, + ["arch"] = RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), + ["cpu"] = Environment.ProcessorCount, + ["machine"] = $"{Android.OS.Build.Manufacturer} {Android.OS.Build.Model}", + ["timestamp"] = DateTimeOffset.Now.ToString("yyyy-MM-ddTHH:mm:sszzz"), + ["pixelFormat"] = "bgr", + ["working_set_mb_loaded"] = wsLoaded, + ["working_set_mb_peak"] = wsPeak, + ["working_set_mb_last"] = rows.Count > 0 ? rows[^1].WorkingSetMb : wsLoaded, + ["advSimdSupported"] = System.Runtime.Intrinsics.Arm.AdvSimd.IsSupported, + ["effectiveIsa"] = System.Runtime.Intrinsics.Arm.AdvSimd.IsSupported ? "advsimd" : "vector", + ["sampleCount"] = decoded.Length, + ["framework"] = RuntimeInformation.FrameworkDescription, + ["libraryTfm"] = typeof(PaddleOcrAll).Assembly + .GetCustomAttributes(typeof(System.Runtime.Versioning.TargetFrameworkAttribute), false) + .OfType().FirstOrDefault()?.FrameworkName, + ["thermal"] = Thermal(), + }; + var accuracy = BenchSummary.ComputeAccuracy(rows, metadataPath, boxes); + if (accuracy is not null) meta["accuracy"] = BenchSummary.AccuracyNode(accuracy); + JsonObject doc = BenchSummary.WrapWithMeta(rows, meta); + string outPath = Path.Combine(Bench.OutDir, outName ?? $"e2e-{model}-{backendName}.json"); + File.WriteAllText(outPath, doc.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + })); + Console.WriteLine($"saved: {outPath}"); + BenchSummary.Print(BenchSummary.Parse(outPath, null, metadataPath)); + var measured = rows.Where(r => !r.Warmup).Select(r => r.TotalMs).OrderBy(x => x).ToArray(); + var tail = rows.Where(r => !r.Warmup).TakeLast(50).Select(r => r.TotalMs).OrderBy(x => x).ToArray(); + if (measured.Length > 0 && tail.Length > 0) + Console.WriteLine($"steady: all-med {measured[measured.Length / 2]:F1} last50-med {tail[tail.Length / 2]:F1} ws-peak {wsPeak:F0}MB thermal={Thermal()}"); + return 0; + } + + static float[] Aabb(PaddleOcrDetectionBox b) => + [ + Math.Min(Math.Min(b.X1, b.X2), Math.Min(b.X3, b.X4)), + Math.Min(Math.Min(b.Y1, b.Y2), Math.Min(b.Y3, b.Y4)), + Math.Max(Math.Max(b.X1, b.X2), Math.Max(b.X3, b.X4)), + Math.Max(Math.Max(b.Y1, b.Y2), Math.Max(b.Y3, b.Y4)), + ]; + + internal static double WorkingSetMb() + { + using Process p = Process.GetCurrentProcess(); + p.Refresh(); + return p.WorkingSet64 / (1024d * 1024d); + } + + /// PowerManager thermal status (0 none .. 6 shutdown) or -1. + internal static int Thermal() + { + try + { + var pm = (Android.OS.PowerManager?)Android.App.Application.Context.GetSystemService(Android.Content.Context.PowerService); + return pm is null || !OperatingSystem.IsAndroidVersionAtLeast(29) ? -1 : (int)pm.CurrentThermalStatus; + } + catch { return -1; } + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/GemmTest.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/GemmTest.cs new file mode 100644 index 0000000..46de71c --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/GemmTest.cs @@ -0,0 +1,136 @@ +using System.Diagnostics; +using Sdcb.SimdPaddleOCR.Backends.Vulkan; + +namespace Sdcb.SimdPaddleOCR.AndroidBench; + +/// +/// Isolated GEMM check with the graph's conv1x1 binding layout +/// (x, w[N,K], bias, res, o, o2; pc = M, N, K, flags): GPU result vs a +/// double-precision reference on the same fp16 inputs, plus GPU time. +/// +static unsafe class GemmTest +{ + // --gemm [flags] [tileM] [tileN] [reqSg] [reps] + internal static int Run(string[] args) + { + string shader = args[1]; + int M = int.Parse(args[2]), N = int.Parse(args[3]), K = int.Parse(args[4]); + uint flags = args.Length > 5 ? uint.Parse(args[5]) : 0; + uint tm = args.Length > 6 ? uint.Parse(args[6]) : 64, tn = args.Length > 7 ? uint.Parse(args[7]) : 64; + uint reqSg = args.Length > 8 ? uint.Parse(args[8]) : 0; + int reps = args.Length > 9 ? int.Parse(args[9]) : 10; + byte[] spv = shader.EndsWith(".spv") ? File.ReadAllBytes(shader) : LibSpv(shader); + + using var dev = VkDevice.Create(); + var rr = new Random(3); + int nPad = (N + 127) / 128 * 128; + Half[] x = new Half[(long)M * K + 128 * 512], w = new Half[(long)nPad * K], bias = new Half[N], res = new Half[(long)M * N]; + for (long i = 0; i < (long)M * K; i++) x[i] = (Half)(rr.NextDouble() * 2 - 1); + for (long i = 0; i < (long)N * K; i++) w[i] = (Half)((rr.NextDouble() * 2 - 1) * 0.1); + for (int i = 0; i < N; i++) bias[i] = (Half)(rr.NextDouble() - 0.5); + for (long i = 0; i < res.Length; i++) res[i] = (Half)(rr.NextDouble() - 0.5); + + VkBuffer Up(Half[] h) + { + var b = dev.NewStorageBuffer((ulong)Math.Max(h.Length, 64) * 2, hostVisible: false); + fixed (Half* p = h) dev.Upload(b, p, (ulong)h.Length * 2); + return b; + } + VkBuffer bx = Up(x), bw = Up(w), bb = Up(bias), br = Up(res); + ulong outBytes = (ulong)M * (ulong)N * 2 + 4096; + VkBuffer bo = dev.NewStorageBuffer(outBytes, hostVisible: true, preferHost: true); + VkBuffer bo2 = dev.NewStorageBuffer(outBytes, hostVisible: true, preferHost: true); + var pipe = dev.NewPipeline(dev.NewShaderModule(spv), 6, 16, reqSg); + IntPtr set = dev.NewDescriptorSet(pipe.SetLayout); + VkBuffer[] binds = [bx, bw, bb, br, bo, bo2]; + for (uint i = 0; i < 6; i++) dev.BindBuffer(set, i, binds[i]); + uint gx = (uint)((M + tm - 1) / tm), gy = (uint)((N + tn - 1) / tn); + uint[] pc = [(uint)M, (uint)N, (uint)K, flags]; + + IntPtr cmd = dev.NewCommandBuffer(); + IntPtr fence = dev.NewFence(); + var qci = new Vk.VkQueryPoolCreateInfo { SType = 11, QueryType = 2, QueryCount = 2 }; + Vk.Check(Vk.vkCreateQueryPool(dev.Device, &qci, null, out IntPtr qp), "qp"); + double best = double.MaxValue; + ulong* ts = stackalloc ulong[2]; + for (int r = 0; r < reps; r++) + { + var begin = new Vk.VkCommandBufferBeginInfo { SType = VkConst.StCommandBufferBeginInfo }; + Vk.Check(Vk.vkResetCommandBuffer(cmd, 0), "reset"); + Vk.Check(Vk.vkBeginCommandBuffer(cmd, &begin), "begin"); + Vk.vkCmdResetQueryPool(cmd, qp, 0, 2); + Vk.vkCmdBindPipeline(cmd, VkConst.BindPointCompute, pipe.Pipeline); + Vk.vkCmdBindDescriptorSets(cmd, VkConst.BindPointCompute, pipe.Layout, 0, 1, &set, 0, null); + fixed (uint* pp = pc) Vk.vkCmdPushConstants(cmd, pipe.Layout, VkConst.StageComputeShader, 0, 16, pp); + Vk.vkCmdWriteTimestamp(cmd, VkConst.PipelineStageBottomOfPipe, qp, 0); + Vk.vkCmdDispatch(cmd, gx, gy, 1); + Vk.vkCmdWriteTimestamp(cmd, VkConst.PipelineStageBottomOfPipe, qp, 1); + Vk.Check(Vk.vkEndCommandBuffer(cmd), "end"); + dev.Submit(cmd, fence); dev.WaitFence(fence); + Vk.vkGetQueryPoolResults(dev.Device, qp, 0, 2, 16, ts, 8, 1 | 2); + best = Math.Min(best, (ts[1] - ts[0]) * dev.TimestampPeriodNs / 1e6); + } + + Half* o = (Half*)bo.Map(); + bool hasBias = (flags & 1) != 0, hasRes = (flags & 2) != 0; + uint act = (flags >> 4) & 7; + double maxErr = 0; long bad = 0; + var badRows = new SortedDictionary(); + var badCols = new SortedDictionary(); + for (int m = 0; m < M; m++) + for (int n = 0; n < N; n++) + { + double acc = 0; + for (int k = 0; k < K; k++) acc += (double)x[(long)m * K + k] * (double)w[(long)n * K + k]; + if (hasBias) acc += (double)bias[n]; + if (hasRes) acc += (double)res[(long)m * N + n]; + acc = act switch + { + 1 => Math.Max(acc, 0), + 2 => 0.5 * acc * (1 + Erf(acc / Math.Sqrt(2))), + 3 => acc * Math.Clamp(acc * (double)BitConverter.UInt16BitsToHalf((ushort)(flags >> 16)) + 0.5, 0, 1), + 4 => 1 / (1 + Math.Exp(-acc)), + _ => acc, + }; + double g = (double)o[(long)m * N + n]; + double e = Math.Abs(g - acc); + if (double.IsNaN(g)) e = double.PositiveInfinity; + maxErr = Math.Max(maxErr, e); + if (e > 0.02 + 0.01 * Math.Abs(acc)) + { + bad++; + badRows[m % 64] = badRows.GetValueOrDefault(m % 64) + 1; + badCols[n % 64] = badCols.GetValueOrDefault(n % 64) + 1; + } + } + bo.Unmap(); + double tflops = 2.0 * M * N * K / (best * 1e-3) / 1e12; + Console.WriteLine($"gemm {shader} M={M} N={N} K={K} flags={flags} grid={gx}x{gy} reqSg={reqSg}: best={best:F4} ms ({tflops:F3} TFLOPS) maxErr={maxErr:G4} bad={bad}/{(long)M * N}"); + if (bad > 0) + { + Console.WriteLine(" bad by row%64: " + string.Join(" ", badRows.Select(kv => $"{kv.Key}:{kv.Value}"))); + Console.WriteLine(" bad by col%64: " + string.Join(" ", badCols.Select(kv => $"{kv.Key}:{kv.Value}"))); + } + Vk.vkDestroyQueryPool(dev.Device, qp, null); + foreach (var b in binds) b.Free(); + return bad == 0 ? 0 : 3; + } + + // Abramowitz-Stegun 7.1.26 (|err| < 1.5e-7) + static double Erf(double v) + { + double s = Math.Sign(v); v = Math.Abs(v); + double t = 1 / (1 + 0.3275911 * v); + double y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.Exp(-v * v); + return s * y; + } + + static byte[] LibSpv(string name) + { + using Stream s = typeof(VkDevice).Assembly.GetManifestResourceStream( + $"Sdcb.SimdPaddleOCR.Backends.Vulkan.Shaders.{name}.spv") ?? throw new FileNotFoundException(name); + byte[] b = new byte[s.Length]; + s.ReadExactly(b); + return b; + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/LayerProbe.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/LayerProbe.cs new file mode 100644 index 0000000..9470bc1 --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/LayerProbe.cs @@ -0,0 +1,53 @@ +using Sdcb.SimdPaddleOCR.Backends.Vulkan; +using Sdcb.SimdPaddleOCR.OnnxSharp; + +namespace Sdcb.SimdPaddleOCR.AndroidBench; + +/// +/// Bisection aid: truncates the GPU graph after node k and reads that node's +/// output back through the graph's own fp32 writeback, printing statistics. +/// Plain .NET, so the desktop GpuBench links it too for reference numbers. +/// +static class LayerProbe +{ + // --layers [firstNode] [lastNode] + internal static int Run(string onnx, int n, int h, int w, int first = 0, int last = int.MaxValue) + { + var mdl = Model.Load(File.ReadAllBytes(onnx)); + var compiled = new CompiledModel(mdl, intraOpThreads: 4); + int[] shape = [n, 3, h, w]; + var inp = new float[n * 3 * h * w]; + var rr = new Random(7); + for (int i = 0; i < inp.Length; i++) inp[i] = (float)(rr.NextDouble() * 2 - 1); + using var dev = VkDevice.Create(); + using var gg = new GpuDetGraph(dev, compiled); + Console.WriteLine($"device={dev.DeviceName} nodes={mdl.Nodes.Length}"); + var nodes = mdl.Nodes; + for (int k = first; k <= Math.Min(last, nodes.Length - 1); k++) + { + int t = checked((int)nodes[k].Outputs[0]); + GpuSchedule s; + try { s = gg.Prepare(shape, k + 1, t); } + catch (Exception ex) { Console.WriteLine($"n{k,-4} {nodes[k].Operator,-14} plan failed: {ex.GetBaseException().Message}"); continue; } + int p = t; + while (s.Alias[p] != p) p = s.Alias[p]; + if (s.Off[p] < 0 || s.Recs.Length < 2) + { + if (Environment.GetEnvironmentVariable("LAYERS_VERBOSE") == "1") + Console.WriteLine($"n{k,-4} {nodes[k].Operator,-14} skipped: t{t} phys{p} off={s.Off[p]} recs={s.Recs.Length}"); + continue; + } + float[] v = gg.Run(shape, inp, k + 1, t).ToArray(); + double sum = 0, sumAbs = 0; float mn = float.MaxValue, mx = float.MinValue; int nz = 0, nan = 0; + foreach (float x in v) + { + if (float.IsNaN(x) || float.IsInfinity(x)) { nan++; continue; } + mn = MathF.Min(mn, x); mx = MathF.Max(mx, x); sum += x; sumAbs += Math.Abs(x); + if (x != 0) nz++; + } + string last2 = string.Join(" | ", s.Recs.Take(s.Recs.Length - 1).TakeLast(2).Select(r => r.Tag)); + Console.WriteLine($"n{k,-4} {nodes[k].Operator,-14} numel={v.Length,-9} mean={sum / v.Length,10:G5} meanAbs={sumAbs / v.Length,10:G5} min={mn,10:G5} max={mx,10:G5} nz={nz}/{v.Length} bad={nan} recs={s.Recs.Length} [{last2}]"); + } + return 0; + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/MainActivity.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/MainActivity.cs new file mode 100644 index 0000000..0852a78 --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/MainActivity.cs @@ -0,0 +1,118 @@ +using Android.App; +using Android.OS; +using Android.Views; + +namespace Sdcb.SimdPaddleOCR.AndroidBench; + +/// +/// adb entry point. Extras: args (space-separated bench arguments), +/// env (K=V;K=V, applied before the library is touched so its +/// static env switches see them) and run (output id). Everything the +/// bench prints goes to logcat (tag SimdOcrBench) and to +/// files/out/<run>.log; <run>.done holds the exit code. +/// The process kills itself afterwards so every run starts cold. +/// +[Activity(Label = "SimdOcrBench", MainLauncher = true, Exported = true, + Name = "com.sdcb.simdocr.bench.MainActivity")] +public class MainActivity : Activity +{ + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + Window?.AddFlags(WindowManagerFlags.KeepScreenOn); + string args = Intent?.GetStringExtra("args") ?? "--caps"; + string env = Intent?.GetStringExtra("env") ?? ""; + string run = Intent?.GetStringExtra("run") ?? "run"; + string root = GetExternalFilesDir(null)!.AbsolutePath; + string outDir = Path.Combine(root, "out"); + // app-created, so the app can read what adb pushes into them later + // (directories made by the shell user are not readable by the app) + foreach (string d in new[] { outDir, Path.Combine(root, "models"), Path.Combine(root, "dataset") }) + Directory.CreateDirectory(d); + File.Delete(Path.Combine(outDir, run + ".done")); + + foreach (string kv in env.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + int eq = kv.IndexOf('='); + if (eq > 0) System.Environment.SetEnvironmentVariable(kv[..eq], kv[(eq + 1)..]); + } + + var worker = new Thread(() => + { + int rc; + using (var log = new TeeWriter(Path.Combine(outDir, run + ".log"))) + { + Console.SetOut(log); + Console.SetError(log); + Console.WriteLine($"args: {args}"); + if (env.Length > 0) Console.WriteLine($"env: {env}"); + try + { + Directory.SetCurrentDirectory(root); + rc = Bench.Run(args.Split(' ', StringSplitOptions.RemoveEmptyEntries), root, outDir); + } + catch (Exception ex) + { + Console.WriteLine($"FATAL {ex}"); + rc = 99; + } + Console.WriteLine($"exit {rc}"); + } + File.WriteAllText(Path.Combine(outDir, run + ".done"), rc.ToString()); + Process.KillProcess(Process.MyPid()); + }, 64 << 20); + worker.Start(); + } +} + +/// Line-buffered tee to logcat and a file. +sealed class TeeWriter(string path) : TextWriter +{ + private readonly StreamWriter _file = new(path, append: false) { AutoFlush = true }; + private readonly System.Text.StringBuilder _line = new(); + private readonly object _sync = new(); + public override System.Text.Encoding Encoding => System.Text.Encoding.UTF8; + + public override void Write(char value) + { + lock (_sync) + { + if (value == '\n') { FlushLine(); return; } + if (value != '\r') _line.Append(value); + } + } + + public override void Write(string? value) + { + if (value is null) return; + lock (_sync) + foreach (char c in value) + { + if (c == '\n') FlushLine(); + else if (c != '\r') _line.Append(c); + } + } + + public override void WriteLine(string? value) + { + lock (_sync) { Write(value); FlushLine(); } + } + + private void FlushLine() + { + string s = _line.ToString(); + _line.Clear(); + Android.Util.Log.Info("SimdOcrBench", s); + _file.WriteLine(s); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + lock (_sync) if (_line.Length > 0) FlushLine(); + _file.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Paths.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/Paths.cs new file mode 100644 index 0000000..0a11466 --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/Paths.cs @@ -0,0 +1,42 @@ +namespace Sdcb.SimdPaddleOCR.AndroidBench; + +/// Model / dataset locations and image decoding shared by the phone +/// host and the desktop GpuBench (which links this file). +static class Paths +{ + internal static string Root = ""; + internal static string ModelsDir = "models"; + internal static string DatasetDir = "dataset"; + + internal static string Path_(string p) => Path.IsPathRooted(p) ? p : Path.Combine(Root, p); + internal static string ModelPath(string model, string kind) => + kind == "cls" ? Path.Combine(Path_(ModelsDir), "cls.onnx") : Path.Combine(Path_(ModelsDir), $"{model}-{kind}.onnx"); + internal static string DictPath(string model) => Path.Combine(Path_(ModelsDir), $"{model}-dict.txt"); + internal static string Dataset => Path_(DatasetDir); + + internal static OcrBackend ParseBackend(string s) => s.ToLowerInvariant() switch + { + "cpu" or "sharp" => OcrBackend.Cpu, + "vulkan" => OcrBackend.Vulkan, + "auto" => OcrBackend.Auto, + _ => throw new ArgumentException("backend must be cpu, vulkan or auto"), + }; + + /// Decodes like the desktop Tests harness (ImageSharp, BGR24). + internal static (byte[] Bgr, int W, int H) LoadBgr(string path) + { + using var image = SixLabors.ImageSharp.Image.Load(path); + byte[] px = new byte[checked(image.Width * image.Height * 3)]; + for (int y = 0; y < image.Height; y++) + for (int x = 0; x < image.Width; x++) + { + var p = image[x, y]; + int o = (y * image.Width + x) * 3; + px[o] = p.B; px[o + 1] = p.G; px[o + 2] = p.R; + } + return (px, image.Width, image.Height); + } + + internal static string[] DatasetFiles() => Directory.GetFiles(Dataset, "img-*.jpg") + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Prof.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/Prof.cs new file mode 100644 index 0000000..eabc087 --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/Prof.cs @@ -0,0 +1,80 @@ +using System.Diagnostics; +using Sdcb.SimdPaddleOCR.Backends.Vulkan; +using Sdcb.SimdPaddleOCR.OnnxSharp; + +namespace Sdcb.SimdPaddleOCR.AndroidBench; + +/// Pure-GPU profiles (GpuBench --detprof / --recprof) and the +/// concurrent-caller check (GpuBench --conc), on the phone. +static class Prof +{ + internal static int Run(string[] args) + { + bool det = args[0] == "--detprof"; + string model = args[1]; + int a = int.Parse(args[2]), b = int.Parse(args[3]); + int reps = args.Length >= 5 ? int.Parse(args[4]) : 20; + var mdl = Model.Load(File.ReadAllBytes(Paths.ModelPath(model, det ? "det" : "rec"))); + var compiled = new CompiledModel(mdl, intraOpThreads: 8); + int[] shape = det ? [1, 3, a, b] : [a, 3, 48, b]; + var inp = new float[shape[0] * shape[1] * shape[2] * shape[3]]; + var rr = new Random(1); + for (int i = 0; i < inp.Length; i++) inp[i] = (float)(rr.NextDouble() * 2 - 1); + int nodeLimit = int.MaxValue, outTensor = -1; + if (!det) + { + var cpu = new InferenceSession(compiled); + cpu.Reshape(shape); + cpu.TryRunUntilCtcProjection(inp, out CtcProjectionOperands ops); + nodeLimit = ops.MatMulNodeIndex; + outTensor = checked((int)mdl.Nodes[nodeLimit].Inputs[0]); + Console.WriteLine($"rec: batch={a} T={ops.Rows} C={ops.Inner} cols={ops.Columns}"); + } + using var dev = VkDevice.Create(); + using var gg = new GpuDetGraph(dev, compiled); + double best = double.MaxValue; + var walls = new List(); + for (int r = 0; r < reps; r++) + { + long t0 = Stopwatch.GetTimestamp(); + gg.Run(shape, inp, nodeLimit, outTensor); + double ms = Stopwatch.GetElapsedTime(t0).TotalMilliseconds; + walls.Add(ms); + best = Math.Min(best, ms); + } + walls.Sort(); + Console.WriteLine($"best wall {best:F2} ms (median {walls[walls.Count / 2]:F2}), dispatches={gg.DispatchCount(shape, nodeLimit, outTensor)} thermal={E2e.Thermal()}"); + gg.DumpProfile(shape, nodeLimit, outTensor); + return 0; + } + + // --conc [threads] [backend]: serial reference texts vs concurrent callers + internal static int Conc(string[] args) + { + string model = args[1]; + int threads = args.Length > 2 ? int.Parse(args[2]) : 4; + OcrBackend backend = args.Length > 3 ? Paths.ParseBackend(args[3]) : OcrBackend.Vulkan; + using var ocr = PaddleOcrAll.Load(Paths.ModelPath(model, "det"), Paths.ModelPath(model, "cls"), + Paths.ModelPath(model, "rec"), Paths.DictPath(model), new PaddleOcrOptions + { + Detector = new PaddleOcrDetectorOptions { Backend = backend }, + Recognizer = new PaddleOcrRecognizerOptions { Backend = backend }, + Classifier = new PaddleOcrClassifierOptions { Backend = backend }, + }); + var imgs = Paths.DatasetFiles().Take(24).Select(Paths.LoadBgr).ToArray(); + string Texts(int i) => string.Join("|", ocr.Run(imgs[i].Bgr, imgs[i].W, imgs[i].H).Lines.Select(l => l.Text)); + string[] reference = Enumerable.Range(0, imgs.Length).Select(Texts).ToArray(); + int bad = 0; + double wsPeak = E2e.WorkingSetMb(); + var tc = Stopwatch.StartNew(); + Parallel.For(0, imgs.Length * 3, new ParallelOptions { MaxDegreeOfParallelism = threads }, j => + { + int i = j % imgs.Length; + if (Texts(i) != reference[i]) { Interlocked.Increment(ref bad); Console.WriteLine($"MISMATCH img{i}"); } + double ws = E2e.WorkingSetMb(); + lock (reference) wsPeak = Math.Max(wsPeak, ws); + }); + Console.WriteLine($"conc model={model} backend={backend} usesGpu={GpuBackend.UsesGpu(backend)} threads={threads} runs={imgs.Length * 3} mismatches={bad} wall={tc.ElapsedMilliseconds}ms ws-peak={wsPeak:F0}MB"); + return bad == 0 ? 0 : 5; + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Sdcb.SimdPaddleOCR.AndroidBench.csproj b/test/Sdcb.SimdPaddleOCR.AndroidBench/Sdcb.SimdPaddleOCR.AndroidBench.csproj new file mode 100644 index 0000000..3e62c09 --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/Sdcb.SimdPaddleOCR.AndroidBench.csproj @@ -0,0 +1,40 @@ + + + + net10.0-android + 29 + Exe + enable + enable + preview + true + false + Sdcb.SimdPaddleOCR.AndroidBench + Sdcb.SimdPaddleOCR.AndroidBench + com.sdcb.simdocr.bench + SimdOcrBench + 1 + 1.0 + android-arm64 + apk + true + false + true + + embedded + + false + + + + + + + + + + diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/global.json b/test/Sdcb.SimdPaddleOCR.AndroidBench/global.json new file mode 100644 index 0000000..545c92c --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestFeature" + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 b/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 new file mode 100644 index 0000000..c187b62 --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 @@ -0,0 +1,92 @@ +# One command for the phone: [build] -> [adb install] -> [push models/dataset] -> launch -> +# wait for .done -> pull files/out/* into bench-out/android/. On a crash or a +# process that dies without writing .done, logcat is saved next to the log. +# +# .\run.ps1 -Build -Install -Push -Bench "--caps" +# .\run.ps1 -Bench "--e2e --model tiny --backend vulkan --out e2e-tiny-vk.json" -Env "SIMD_OCR_GPU_TIME=1" +param( + [string]$Bench = "--caps", + [string]$Env = "", + [string]$Run = "", + [switch]$Build, + [switch]$Install, + [switch]$Push, + [int]$TimeoutSec = 3600, + [string]$Adb = $(if ($env:ADB) { $env:ADB } else { "D:\_\3rd\android-sdk\platform-tools\adb.exe" }), + [string]$AndroidSdk = $(if ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { "D:\_\3rd\android-sdk" }), + [string]$Jdk = $(if ($env:JAVA_HOME -and (Test-Path "$env:JAVA_HOME\bin\javac.exe") -and ($env:JAVA_HOME -notmatch "jdk-2[2-9]")) { $env:JAVA_HOME } else { "C:\Program Files\Android\openjdk\jdk-21.0.8" }) +) +$ErrorActionPreference = "Stop" +$here = $PSScriptRoot +$root = (Resolve-Path "$here\..\..").Path +$pkg = "com.sdcb.simdocr.bench" +$remote = "/sdcard/Android/data/$pkg/files" +$local = "$root\bench-out\android" +New-Item -ItemType Directory -Force $local | Out-Null +if (-not $Run) { $Run = "r" + (Get-Date -Format "MMdd-HHmmss") } + +if ($Build) { + Push-Location $here + try { + dotnet build -c Release "-p:AndroidSdkDirectory=$AndroidSdk" "-p:JavaSdkDirectory=$Jdk" -v:q -nologo -clp:ErrorsOnly | Out-Host + if ($LASTEXITCODE -ne 0) { throw "build failed" } + } finally { Pop-Location } +} +if ($Install) { + $apk = "$here\bin\Release\net10.0-android\android-arm64\$pkg-Signed.apk" + & $Adb install -r -g $apk | Out-Host + if ($LASTEXITCODE -ne 0) { throw "adb install failed" } +} + +function Start-Bench([string]$id, [string]$argv, [string]$envs) { + & $Adb shell "rm -f $remote/out/$id.done $remote/out/$id.log" + $cmd = "am start -S -n $pkg/$pkg.MainActivity --es run '$id' --es args '$argv'" + if ($envs) { $cmd += " --es env '$envs'" } + & $Adb shell $cmd | Out-Null +} + +if ($Push) { + # the app must create models/ dataset/ out/ itself: directories made by the + # shell user are not readable by the app + & $Adb shell "rm -rf $remote/models $remote/dataset $remote/out" + Start-Bench "init" "--init" "" + for ($i = 0; $i -lt 30 -and -not ((& $Adb shell "cat $remote/out/init.done 2>/dev/null") -join ""); $i++) { Start-Sleep 1 } + & $Adb push "$root\bench-out\models\." "$remote/models/" | Out-Host + & $Adb push "$root\dataset\." "$remote/dataset/" | Out-Host +} + +& $Adb logcat -c +Start-Bench $Run $Bench $Env +Write-Output "[$Run] $Bench $(if ($Env) { "(env $Env)" })" + +$t0 = Get-Date +$seen = 0 +$started = $false +$result = $null +while (((Get-Date) - $t0).TotalSeconds -lt $TimeoutSec) { + Start-Sleep -Seconds 2 + $done = (& $Adb shell "cat $remote/out/$Run.done 2>/dev/null") -join "" + $log = & $Adb shell "cat $remote/out/$Run.log 2>/dev/null" + if ($log) { + $lines = @($log) + for ($i = $seen; $i -lt $lines.Count; $i++) { Write-Output $lines[$i] } + $seen = $lines.Count + } + if ($done -match "^\d+$") { $result = [int]$done; break } + $procId = (& $Adb shell "pidof $pkg") -join "" + if ($procId) { $started = $true } + elseif ($started -or ((Get-Date) - $t0).TotalSeconds -gt 30) { break } +} + +& $Adb pull "$remote/out/." "$local" 2>&1 | Out-Null +if ($null -eq $result) { + $lc = "$local\$Run-logcat.txt" + & $Adb logcat -d -v time "SimdOcrBench:*" "DOTNET:*" "AndroidRuntime:*" "DEBUG:*" "libc:*" "ActivityManager:*" "lowmemorykiller:*" "*:F" "*:S" | Out-File -Encoding utf8 $lc + Write-Output "[$Run] process ended without .done — logcat saved to $lc" + exit 98 +} +if ($result -ne 0) { + & $Adb logcat -d -v time "SimdOcrBench:*" "DOTNET:*" "AndroidRuntime:*" "*:F" "*:S" | Out-File -Encoding utf8 "$local\$Run-logcat.txt" +} +Write-Output "[$Run] exit $result -> $local\$Run.log" +exit $result diff --git a/test/Sdcb.SimdPaddleOCR.GpuBench/Harness.cs b/test/Sdcb.SimdPaddleOCR.GpuBench/Harness.cs index 3b92b4d..081685f 100644 --- a/test/Sdcb.SimdPaddleOCR.GpuBench/Harness.cs +++ b/test/Sdcb.SimdPaddleOCR.GpuBench/Harness.cs @@ -1,5 +1,18 @@ namespace Sdcb.SimdPaddleOCR.GpuBench; +static class DesktopPaths +{ + internal static int Run(Func mode, string[] args) + { + string root = AppContext.BaseDirectory; + while (root.Length > 3 && !File.Exists(Path.Combine(root, "Sdcb.SimdPaddleOCR.slnx"))) + root = Path.GetDirectoryName(root.TrimEnd(Path.DirectorySeparatorChar))!; + AndroidBench.Paths.Root = root; + AndroidBench.Paths.ModelsDir = Path.Combine("bench-out", "models"); + return mode(args); + } +} + static class Harness { internal static int Usage(int code) @@ -26,6 +39,10 @@ internal static int Usage(int code) --sessiso --reciso --detmap + --detcmp [maxSide] CPU vs Vulkan DET map + boxes + --reccmp CPU vs Vulkan REC activations + --gemm [flags] [tileM] [tileN] [reqSg] [reps] GEMM vs fp64 reference + --layers [first] [last] per-node output stats of the truncated GPU graph --recreal [W] """); return code; diff --git a/test/Sdcb.SimdPaddleOCR.GpuBench/Program.cs b/test/Sdcb.SimdPaddleOCR.GpuBench/Program.cs index 9b9a2c2..4c36010 100644 --- a/test/Sdcb.SimdPaddleOCR.GpuBench/Program.cs +++ b/test/Sdcb.SimdPaddleOCR.GpuBench/Program.cs @@ -13,5 +13,11 @@ "--rec" => RecCompare.Run(args), "--recprof" or "--sessiso" or "--reciso" or "--recreal" => RecBench.Run(args), "--conc" or "--pipe" => PipelineBench.Run(args), + "--gemm" => Sdcb.SimdPaddleOCR.AndroidBench.GemmTest.Run(args), + // same checks as the phone host; models from bench-out/models, images from dataset/ + "--detcmp" or "--reccmp" => DesktopPaths.Run(Sdcb.SimdPaddleOCR.AndroidBench.Compare.Run, args), + "--layers" when args.Length >= 5 => Sdcb.SimdPaddleOCR.AndroidBench.LayerProbe.Run(args[1], int.Parse(args[2]), + int.Parse(args[3]), int.Parse(args[4]), args.Length > 5 ? int.Parse(args[5]) : 0, + args.Length > 6 ? int.Parse(args[6]) : int.MaxValue), _ => Harness.Usage(2), }; diff --git a/test/Sdcb.SimdPaddleOCR.GpuBench/Sdcb.SimdPaddleOCR.GpuBench.csproj b/test/Sdcb.SimdPaddleOCR.GpuBench/Sdcb.SimdPaddleOCR.GpuBench.csproj index 4481ab0..01aaccf 100644 --- a/test/Sdcb.SimdPaddleOCR.GpuBench/Sdcb.SimdPaddleOCR.GpuBench.csproj +++ b/test/Sdcb.SimdPaddleOCR.GpuBench/Sdcb.SimdPaddleOCR.GpuBench.csproj @@ -18,5 +18,9 @@ + + + + From 981b6fecab6158df03c73b9a938fb231b93368fe Mon Sep 17 00:00:00 2001 From: sdcb Date: Mon, 28 Sep 2026 22:09:52 +0800 Subject: [PATCH 03/12] vulkan: direct-load gemm_nc where no narrow subgroup can be pinned On Adreno 750 (no cooperative matrix, compute subgroups 64-128, so the no-coopmat tier with the driver-chosen wave64) gemm_nc returned garbage: tiny DET's probability map was all zeros while the session reported the GPU alive. The layer probe put the first divergence from the 3080 Ti at the first conv routed to gemm_nc_s, and --gemm reproduced it alone (6400x32x64: 188871 of 204800 outputs wrong; 1024x128x256: 126660 of 131072). Variants: plain fma vs mul+add and if-guarded vs select loads change nothing; dropping the register prefetch of the next A/B tile, or shrinking the tile to 32x32 with 4x4 accumulators, makes it exact. The driver's compiler mishandles the f16vec4 prefetch arrays carried across the K loop next to 64 fp32 accumulators. gemm_nc.comp gains -DDIRECT (each tile goes from global straight into shared memory, same fp32 accumulation order in K) built as gemm_nc_d / gemm_nc_ds. The tier takes them whenever it cannot pin an 8-lane subgroup; where it can (UHD 770) gemm_nc / gemm_nc_s with requiredSubgroupSize=8 stay as they were. gemm_nc.spv and gemm_nc_s.spv rebuild byte-identical. GT5 Pro, --gemm vs an fp64 reference: 0 wrong everywhere, and faster than the broken form (1024x128x256 0.254 -> 0.211 ms, 6400x32x64 0.231 -> 0.191 ms). tiny DET vs the phone CPU: box counts equal on 10/10 images, map maxAbs 0.05-0.15 (3080 Ti vs its CPU: 0.03-0.18). --- .../Backends/Vulkan/GpuGraph.cs | 8 +++++--- .../Backends/Vulkan/Shaders/gemm_nc.comp | 18 ++++++++++++++++++ .../Backends/Vulkan/Shaders/gemm_nc_d.spv | Bin 0 -> 10088 bytes .../Backends/Vulkan/Shaders/gemm_nc_ds.spv | Bin 0 -> 8152 bytes .../Backends/Vulkan/tools/build-shaders.bat | 3 +++ 5 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc_d.spv create mode 100644 src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc_ds.spv diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs index 7e475e1..0655821 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs @@ -197,10 +197,12 @@ private GpuGraphModel(VkDevice dev, CompiledModel compiled) { // One tile; n64/n32 stay aliases so CmTile's sg16 cout split is // not reused here. 8 lanes keep its 64 fp32 accumulators in - // registers (UHD 770: SIMD16 spills, 4x slower). + // registers (UHD 770: SIMD16 spills, 4x slower). Without an + // 8-lane pin (wave64 Adreno) the register-prefetch form is + // miscompiled there; the direct-load build is exact and faster. bool sg8 = dev.ComputeSubgroupSize && dev.SubgroupMin <= 8 && dev.SubgroupMax >= 8; - _pConv1x1 = Pipe("gemm_nc", 6, 16, sg8 ? 8u : 0u); - _pGemmNcS = Pipe("gemm_nc_s", 6, 16, sg8 ? 8u : 0u); + _pConv1x1 = sg8 ? Pipe("gemm_nc", 6, 16, 8u) : Pipe("gemm_nc_d", 6, 16); + _pGemmNcS = sg8 ? Pipe("gemm_nc_s", 6, 16, 8u) : Pipe("gemm_nc_ds", 6, 16); _pConv1x1N64 = _pConv1x1; _pConv1x1N32 = _pConv1x1; } diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc.comp b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc.comp index e538ff9..3a92891 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc.comp +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc.comp @@ -15,6 +15,10 @@ // wider SIMD spills them. Bindings and the 16-byte push constant match // conv1x1_cm. // +// -DDIRECT (devices that cannot pin a narrow subgroup, e.g. Adreno wave64): +// each tile goes straight from global memory into shared memory, no +// register prefetch. Adreno's compiler miscomputes the prefetch form. +// // flags: bit0 hasBias, bit1 hasResidual, bit3 dual output, bits[4:7] activation // act: 0=none 1=relu 2=gelu 3=hardswish 4=sigmoid // bit10 (1024): dual output stores the pre-activation value @@ -90,6 +94,7 @@ void main() for (uint i = 0u; i < TM * TN; i++) acc[i] = 0.0; f16vec4 z = f16vec4(float16_t(0.0)); +#ifndef DIRECT f16vec4 ra[AV]; f16vec4 rb[BV]; for (uint t = 0u; t < AV; t++) @@ -106,6 +111,7 @@ void main() uint gn = n0 + r, gk = cv * 4u; rb[t] = (e < BN * AK && gn < p.nDim && gk < p.kDim) ? w[gn * kv + (gk >> 2u)] : z; } +#endif for (uint k0 = 0u; k0 < p.kDim; k0 += BK) { @@ -115,7 +121,12 @@ void main() if (e < BM * AK) { uint r = e / AK, cv = e - r * AK; +#ifdef DIRECT + uint gm = m0 + r, gk = k0 + cv * 4u; + vec4 f = vec4((gm < p.mDim && gk < p.kDim) ? x[gm * kv + (gk >> 2u)] : z); +#else vec4 f = vec4(ra[t]); +#endif As((cv * 4u + 0u) * BM + r) = f.x; As((cv * 4u + 1u) * BM + r) = f.y; As((cv * 4u + 2u) * BM + r) = f.z; @@ -128,7 +139,12 @@ void main() if (e < BN * AK) { uint r = e / AK, cv = e - r * AK; +#ifdef DIRECT + uint gn = n0 + r, gk = k0 + cv * 4u; + vec4 f = vec4((gn < p.nDim && gk < p.kDim) ? w[gn * kv + (gk >> 2u)] : z); +#else vec4 f = vec4(rb[t]); +#endif Bs((cv * 4u + 0u) * BN + r) = f.x; Bs((cv * 4u + 1u) * BN + r) = f.y; Bs((cv * 4u + 2u) * BN + r) = f.z; @@ -136,6 +152,7 @@ void main() } } barrier(); +#ifndef DIRECT uint k1 = k0 + BK; if (k1 < p.kDim) { @@ -154,6 +171,7 @@ void main() rb[t] = (e < BN * AK && gn < p.nDim && gk < p.kDim) ? w[gn * kv + (gk >> 2u)] : z; } } +#endif for (uint kk = 0u; kk < BK; kk++) { float a[TM]; diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc_d.spv b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc_d.spv new file mode 100644 index 0000000000000000000000000000000000000000..9f6f4fd1ecdf9e4facbe9062a76a24f7fbfb5562 GIT binary patch literal 10088 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 AdH?_b literal 0 HcmV?d00001 diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/tools/build-shaders.bat b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/tools/build-shaders.bat index 10b9624..f6ab17b 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/tools/build-shaders.bat +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/tools/build-shaders.bat @@ -51,3 +51,6 @@ rem nocm: subgroup-free fp16 GEMM (devices without 16x16x16 coopmat) echo glslc gemm_nc "%GLSLC%" -O --target-env=vulkan1.1 "%DIR%\gemm_nc.comp" -o "%DIR%\gemm_nc.spv" || exit /b 1 "%GLSLC%" -O --target-env=vulkan1.1 -DSIMPLE "%DIR%\gemm_nc.comp" -o "%DIR%\gemm_nc_s.spv" || exit /b 1 +rem nocm without a pinnable narrow subgroup (Adreno wave64): no register prefetch +"%GLSLC%" -O --target-env=vulkan1.1 -DDIRECT "%DIR%\gemm_nc.comp" -o "%DIR%\gemm_nc_d.spv" || exit /b 1 +"%GLSLC%" -O --target-env=vulkan1.1 -DDIRECT -DSIMPLE "%DIR%\gemm_nc.comp" -o "%DIR%\gemm_nc_ds.spv" || exit /b 1 From 04f38bc20626244e39e8fb01685d9fc2129d39f0 Mon Sep 17 00:00:00 2001 From: sdcb Date: Mon, 28 Sep 2026 22:10:11 +0800 Subject: [PATCH 04/12] vulkan: pad fp16 constant uploads to whole 16 bytes A scalar constant (numel 1) was uploaded as a 2-byte buffer, and elem4 reads it as b[0] of a uint array. Desktop drivers do not bounds-check that load, Adreno does (per binding range) and returns 0 for it: the CLS head's Mul by a constant came out as 0, tiny CLS got 306 of 1022 right and the wrongly rotated lines took REC to CER 57%. ConstF16 and VecF16 now round their element count up to a multiple of 8. Only zeros are appended; every kernel reads the same values as before. GT5 Pro, tiny, --backend vulkan: cls 306 -> 1022/1022, exact_lines 216 -> 743 / 1036, CER 57.5% -> 2.40% (phone CPU: 742, 2.37%). No fallback in the log. --- src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs index 0655821..63cf6ab 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs @@ -316,7 +316,9 @@ private VkBuffer ConstF16(int tensorIndex, int padRows = 0, int rowElems = 0, } else { - alloc = Math.Max(n, padRows * rowElems); + // whole 16 B: kernels read scalars as a packed half pair / f16vec4, + // and Adreno bounds-checks every load against the binding range + alloc = (Math.Max(n, padRows * rowElems) + 7) / 8 * 8; h = new Half[alloc]; for (int i = 0; i < n; i++) h[i] = (Half)f32[i]; } @@ -349,7 +351,7 @@ private ReadOnlySpan CstF32(int tensorIndex) => private VkBuffer VecF16((int, int) key, float[] v) { if (_vecF16.TryGetValue(key, out VkBuffer? cached)) return cached; - Half[] h = new Half[v.Length]; + Half[] h = new Half[(v.Length + 7) / 8 * 8]; for (int i = 0; i < v.Length; i++) h[i] = (Half)v[i]; VkBuffer buf = _dev.NewStorageBuffer((ulong)h.Length * 2, hostVisible: false); unsafe { fixed (Half* p = h) _dev.Upload(buf, p, (ulong)h.Length * 2); } From afb0f38493fab8ea1743a48210406d1360315d84 Mon Sep 17 00:00:00 2001 From: sdcb Date: Mon, 28 Sep 2026 22:23:48 +0800 Subject: [PATCH 05/12] vulkan: keep the im2col matrix within one binding's maxStorageBufferRange Adreno 750 caps a storage binding at 128 MB. medium DET's 7x7 conv at 960x960 input (M 57600, K 1568) builds a 180 MB im2col matrix, and every load past 128 MB from its binding start read 0: the layer probe put the first divergence from the 3080 Ti at that conv (meanAbs -25%), and medium lost lines on large images (818 of 1036 classified, CER 13.2%). A dense kxk conv whose im2col matrix would not fit one binding now takes the direct convk_dot path instead (no K cap there). As a guard, a plan that would still bind more than maxStorageBufferRange (an arena slab, or an im2col the direct path cannot take) throws before anything runs, so the session serves that shape from the CPU instead of returning a partly zero map. Desktop GPUs report multi-GB ranges; none of this triggers there. GT5 Pro medium, --backend vulkan: DET boxes vs phone CPU equal in count on 6/6 images (map maxAbs 0.03-0.07, was up to 1.0); end to end cls 1035/1035, exact_lines 1003 / 1036, CER 0.15% (Windows CPU baseline 1004, 0.14%). Clamping the descriptor ranges themselves changed nothing on this driver, so that part is not included. --- src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs | 11 ++++++++++- src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs | 4 ++++ src/Sdcb.SimdPaddleOCR/Backends/Vulkan/VkDevice.cs | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs index 63cf6ab..fe1d272 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs @@ -669,6 +669,9 @@ bool Perm(int ni, params int[] perm) if (i == inIdx && inCinPad != 0) sz = numel[i] / shapes[i][1] * inCinPad; slabElems[i] = (sz + 7) / 8 * 8 + 128 * 64; + if (slabElems[i] * 2 > (long)_dev.MaxStorageRange) + throw new NotSupportedException( + $"tensor {i} ({sz * 2} B) exceeds maxStorageBufferRange {_dev.MaxStorageRange}"); cursor += slabElems[i]; } // the im2col scratch sits past every slab; its size (and the arena @@ -1411,8 +1414,11 @@ bool Absorbable(int cn, int srcPhys) // sg32: implicit-GEMM coopmat conv instead (same tap // addressing, tensor cores; any K, batched or not) bool convkCm = _sg32 && cinIn % 8 == 0 && scalarBias == 0; + // the im2col matrix is one binding: past + // maxStorageBufferRange (Adreno: 128 MB) go direct + bool im2colFits = (long)M * Kp * 2 <= (long)_dev.MaxStorageRange; if (convkCm || (cout % 4 == 0 && scalarBias == 0 && cinIn % 4 == 0 - && (nb > 1 || Kp <= (Environment.GetEnvironmentVariable( + && (nb > 1 || !im2colFits || Kp <= (Environment.GetEnvironmentVariable( "SIMD_OCR_CONVD_KMAX") is string km ? int.Parse(km) : 1024)) && Environment.GetEnvironmentVariable("SIMD_OCR_NODCONV") == null)) @@ -1498,6 +1504,9 @@ bool Absorbable(int cn, int srcPhys) if (nb > 1) throw new NotSupportedException( $"im2col conv path lacks batch support (n{ni})"); + if (!im2colFits) + throw new NotSupportedException( + $"im2col matrix {M}x{Kp} exceeds maxStorageBufferRange (n{ni})"); maxIm2col = Math.Max(maxIm2col, (long)M * Kp); Emit(_pIm2col, $"im2col n{ni} M{M} K{K}", [(arena, SlotOf(node.Inputs[0]), 2), diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs index 3e90b71..7a315ee 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Vk.cs @@ -81,6 +81,10 @@ public uint MaxComputeSharedMemorySize { get { fixed (byte* p = LimitsAndSparse) return *(uint*)(p + 4 + 216); } } + public uint MaxStorageBufferRange + { + get { fixed (byte* p = LimitsAndSparse) return *(uint*)(p + 4 + 28); } + } } [StructLayout(LayoutKind.Sequential)] diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/VkDevice.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/VkDevice.cs index a0519e5..e00c3f5 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/VkDevice.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/VkDevice.cs @@ -24,6 +24,7 @@ internal unsafe sealed class VkDevice : IDisposable public bool Coop8x16x16; // same types, MxNxK 8x16x16 (Arc) public uint SubgroupMin = 1, SubgroupMax = 128; public uint MaxSharedMemory; // maxComputeSharedMemorySize (bytes per workgroup) + public ulong MaxStorageRange = uint.MaxValue; // maxStorageBufferRange (bytes per binding) public int CoopM, CoopN, CoopK; // best fp16->fp32 subgroup coopmat config public bool Storage16Bit; // storageBuffer16BitAccess + shaderInt16 enabled public bool ShaderFloat16; // shaderFloat16 (VK_KHR_shader_float16_int8) enabled — required by fp16 shaders @@ -107,6 +108,7 @@ public static VkDevice Create(uint deviceIndex = 0) d.VendorId = props.VendorID; d.TimestampPeriodNs = props.TimestampPeriodNs; d.MaxSharedMemory = props.MaxComputeSharedMemorySize; + d.MaxStorageRange = props.MaxStorageBufferRange; uint nqf = 0; Vk.vkGetPhysicalDeviceQueueFamilyProperties(d.PhysDevice, &nqf, null); From b865f9792c2fb7019fd54b2d42a98436411aa229 Mon Sep 17 00:00:00 2001 From: sdcb Date: Mon, 28 Sep 2026 22:56:38 +0800 Subject: [PATCH 06/12] test(android): GEMM variant sweep, FMA peak probe, pure-GPU profile script --gemmsweep checks each .spv variant on an odd shape against fp64, then times it on the eleven hottest GEMM shapes of medium/small DET and REC, variants interleaved per round so clock / thermal drift hits all of them alike; --peak measures fp32 / fp16 FMA throughput from registers. gprof.ps1 is the phone twin of GpuBench's gprof.ps1 (same case list, per-dispatch timestamps, min over reps). run.ps1 no longer reports a finished run as dead when the app exits right after writing .done. --- test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs | 2 + .../GemmTest.cs | 296 ++++++++++++------ .../Sdcb.SimdPaddleOCR.AndroidBench.csproj | 2 + .../Shaders/peak.comp | 38 +++ .../Shaders/peak.spv | Bin 0 -> 4136 bytes .../Shaders/peak16.spv | Bin 0 -> 4348 bytes .../Sdcb.SimdPaddleOCR.AndroidBench/gprof.ps1 | 21 ++ test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 | 10 +- 8 files changed, 275 insertions(+), 94 deletions(-) create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak.comp create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak.spv create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak16.spv create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/gprof.ps1 diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs index c6b485d..fb93622 100644 --- a/test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/Bench.cs @@ -20,6 +20,8 @@ internal static int Run(string[] args, string root, string outDir) "--conc" => Prof.Conc(args), "--rawbench" => Caps.RawBench(args), "--gemm" => GemmTest.Run(args), + "--gemmsweep" => GemmTest.Sweep(args), + "--peak" => GemmTest.Peak(args), // --layers [first] [last] "--layers" => LayerProbe.Run(Paths.ModelPath(args[1], args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5]), args.Length > 6 ? int.Parse(args[6]) : 0, diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/GemmTest.cs b/test/Sdcb.SimdPaddleOCR.AndroidBench/GemmTest.cs index 46de71c..b6d6292 100644 --- a/test/Sdcb.SimdPaddleOCR.AndroidBench/GemmTest.cs +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/GemmTest.cs @@ -1,12 +1,12 @@ -using System.Diagnostics; +using System.Text.RegularExpressions; using Sdcb.SimdPaddleOCR.Backends.Vulkan; namespace Sdcb.SimdPaddleOCR.AndroidBench; /// -/// Isolated GEMM check with the graph's conv1x1 binding layout -/// (x, w[N,K], bias, res, o, o2; pc = M, N, K, flags): GPU result vs a -/// double-precision reference on the same fp16 inputs, plus GPU time. +/// GEMM checks with the graph's conv1x1 binding layout (x, w[N,K], bias, +/// res, o, o2; pc = M, N, K, flags): one dispatch vs an fp64 reference, +/// a variant sweep over hot shapes, and an FMA peak probe. /// static unsafe class GemmTest { @@ -19,101 +19,220 @@ internal static int Run(string[] args) uint tm = args.Length > 6 ? uint.Parse(args[6]) : 64, tn = args.Length > 7 ? uint.Parse(args[7]) : 64; uint reqSg = args.Length > 8 ? uint.Parse(args[8]) : 0; int reps = args.Length > 9 ? int.Parse(args[9]) : 10; - byte[] spv = shader.EndsWith(".spv") ? File.ReadAllBytes(shader) : LibSpv(shader); + using var dev = VkDevice.Create(); + using var h = new Harness(dev, (long)M * K, (long)N * K, (long)M * N, N); + var pipe = dev.NewPipeline(dev.NewShaderModule(Spv(shader)), 6, 16, reqSg); + double best = h.Time(pipe, M, N, K, flags, tm, tn, reps); + var (maxErr, bad) = h.Verify(M, N, K, flags); + Console.WriteLine($"gemm {shader} M={M} N={N} K={K} flags={flags} tile={tm}x{tn} reqSg={reqSg}: best={best:F4} ms ({2.0 * M * N * K / (best * 1e-3) / 1e12:F3} TFLOPS) maxErr={maxErr:G4} bad={bad}/{(long)M * N}"); + return bad == 0 ? 0 : 3; + } + + static readonly (int M, int N, int K)[] s_hot = + [ + (5760, 1024, 512), (5760, 512, 1024), (2880, 768, 1536), (2880, 1536, 768), + (57600, 256, 128), (57600, 128, 256), (14400, 512, 256), (3600, 1024, 512), + (900, 896, 1792), (57600, 64, 256), (11520, 256, 512), + ]; + + // --gemmsweep [rounds] [flags]: every variant is checked on + // an odd shape against fp64, then timed on the hot shapes, variants + // interleaved per round; min per shape over rounds + internal static int Sweep(string[] args) + { + string[] shaders = args[1].Split(','); + int rounds = args.Length > 2 ? int.Parse(args[2]) : 3; + uint flags = args.Length > 3 ? uint.Parse(args[3]) : 0; + using var dev = VkDevice.Create(); + using var h = new Harness(dev, s_hot.Max(s => (long)s.M * s.K), s_hot.Max(s => (long)s.N * s.K), + s_hot.Max(s => (long)s.M * s.N), s_hot.Max(s => s.N)); + var pipes = new VkPipeline[shaders.Length]; + var tiles = new (uint, uint)[shaders.Length]; + for (int v = 0; v < shaders.Length; v++) + { + pipes[v] = dev.NewPipeline(dev.NewShaderModule(Spv(shaders[v])), 6, 16, 0); + tiles[v] = Tile(shaders[v]); + h.Time(pipes[v], 1000, 136, 264, flags | 3u, tiles[v].Item1, tiles[v].Item2, 1); + var (err, bad) = h.Verify(1000, 136, 264, flags | 3u); + Console.WriteLine($"variant {v} {shaders[v]} tile={tiles[v].Item1}x{tiles[v].Item2}: verify maxErr={err:G3} bad={bad}"); + } + var best = new double[shaders.Length, s_hot.Length]; + for (int v = 0; v < shaders.Length; v++) for (int s = 0; s < s_hot.Length; s++) best[v, s] = double.MaxValue; + for (int r = 0; r < rounds; r++) + for (int s = 0; s < s_hot.Length; s++) + for (int v = 0; v < shaders.Length; v++) + { + var (M, N, K) = s_hot[s]; + best[v, s] = Math.Min(best[v, s], h.Time(pipes[v], M, N, K, flags, tiles[v].Item1, tiles[v].Item2, 3)); + } + Console.WriteLine("shape".PadRight(18) + string.Concat(Enumerable.Range(0, shaders.Length).Select(v => $"{"v" + v,10}"))); + var sum = new double[shaders.Length]; + for (int s = 0; s < s_hot.Length; s++) + { + var (M, N, K) = s_hot[s]; + var line = new System.Text.StringBuilder($"{M}x{N}x{K}".PadRight(18)); + for (int v = 0; v < shaders.Length; v++) { line.Append($"{best[v, s],10:F3}"); sum[v] += best[v, s]; } + Console.WriteLine(line); + } + Console.WriteLine("sum ms".PadRight(18) + string.Concat(sum.Select(x => $"{x,10:F2}"))); + double flop = s_hot.Sum(s => 2.0 * s.M * s.N * s.K); + Console.WriteLine("TFLOPS".PadRight(18) + string.Concat(sum.Select(x => $"{flop / (x * 1e-3) / 1e12,10:F3}"))); + Console.WriteLine($"thermal={E2eThermal()}"); + return 0; + } + // --peak: fp32 / fp16 FMA throughput of 16 independent vec4 chains per thread + internal static int Peak(string[] args) + { using var dev = VkDevice.Create(); - var rr = new Random(3); - int nPad = (N + 127) / 128 * 128; - Half[] x = new Half[(long)M * K + 128 * 512], w = new Half[(long)nPad * K], bias = new Half[N], res = new Half[(long)M * N]; - for (long i = 0; i < (long)M * K; i++) x[i] = (Half)(rr.NextDouble() * 2 - 1); - for (long i = 0; i < (long)N * K; i++) w[i] = (Half)((rr.NextDouble() * 2 - 1) * 0.1); - for (int i = 0; i < N; i++) bias[i] = (Half)(rr.NextDouble() - 0.5); - for (long i = 0; i < res.Length; i++) res[i] = (Half)(rr.NextDouble() - 0.5); + using var h = new Harness(dev, 4096, 4096, 4096, 64); + foreach (string name in new[] { "peak.spv", "peak16.spv" }) + { + using Stream s = typeof(GemmTest).Assembly.GetManifestResourceStream(name)!; + byte[] b = new byte[s.Length]; + s.ReadExactly(b); + var pipe = dev.NewPipeline(dev.NewShaderModule(b), 6, 16, 0); + const uint groups = 4096; + double best = h.Time(pipe, 0, 0, 0, 0, 0, 0, 10, groups); + double flop = groups * 256.0 * 32768; + Console.WriteLine($"peak {name}: {best:F3} ms -> {flop / (best * 1e-3) / 1e12:F3} TFLOPS"); + } + return 0; + } + + static int E2eThermal() + { +#if ANDROID + return E2e.Thermal(); +#else + return -1; +#endif + } + + static (uint, uint) Tile(string shader) + { + var bm = Regex.Match(shader, @"bm(\d+)"); + var bn = Regex.Match(shader, @"bn(\d+)"); + return (bm.Success ? uint.Parse(bm.Groups[1].Value) : 64u, bn.Success ? uint.Parse(bn.Groups[1].Value) : 64u); + } + + static byte[] Spv(string shader) + { + if (shader.EndsWith(".spv")) return File.ReadAllBytes(shader); + using Stream s = typeof(VkDevice).Assembly.GetManifestResourceStream( + $"Sdcb.SimdPaddleOCR.Backends.Vulkan.Shaders.{shader}.spv") ?? throw new FileNotFoundException(shader); + byte[] b = new byte[s.Length]; + s.ReadExactly(b); + return b; + } + + /// Random fp16 operands sized for the largest shape, bound once. + sealed class Harness : IDisposable + { + readonly VkDevice _dev; + readonly Half[] _x, _w, _bias, _res; + readonly VkBuffer[] _binds; + readonly IntPtr _cmd, _fence, _qp; + + public Harness(VkDevice dev, long xElems, long wElems, long mn, int maxN) + { + _dev = dev; + var rr = new Random(3); + mn = Math.Max(mn, 1000 * 136); + _x = new Half[Math.Max(xElems, 1000 * 264) + 128 * 512]; + _w = new Half[Math.Max(wElems, 136 * 264) + 128 * 2048]; + _bias = new Half[Math.Max(maxN, 136) + 8]; + _res = new Half[mn]; + for (long i = 0; i < _x.Length; i++) _x[i] = (Half)(rr.NextDouble() * 2 - 1); + for (long i = 0; i < _w.Length; i++) _w[i] = (Half)((rr.NextDouble() * 2 - 1) * 0.1); + for (int i = 0; i < _bias.Length; i++) _bias[i] = (Half)(rr.NextDouble() - 0.5); + for (long i = 0; i < _res.Length; i++) _res[i] = (Half)(rr.NextDouble() - 0.5); + ulong outBytes = (ulong)mn * 2 + 4096; + _binds = [Up(_x), Up(_w), Up(_bias), Up(_res), + dev.NewStorageBuffer(outBytes, hostVisible: true, preferHost: true), + dev.NewStorageBuffer(outBytes, hostVisible: true, preferHost: true)]; + _cmd = dev.NewCommandBuffer(); + _fence = dev.NewFence(); + var qci = new Vk.VkQueryPoolCreateInfo { SType = 11, QueryType = 2, QueryCount = 2 }; + Vk.Check(Vk.vkCreateQueryPool(dev.Device, &qci, null, out _qp), "qp"); + } VkBuffer Up(Half[] h) { - var b = dev.NewStorageBuffer((ulong)Math.Max(h.Length, 64) * 2, hostVisible: false); - fixed (Half* p = h) dev.Upload(b, p, (ulong)h.Length * 2); + var b = _dev.NewStorageBuffer((ulong)Math.Max(h.Length, 64) * 2, hostVisible: false); + fixed (Half* p = h) _dev.Upload(b, p, (ulong)h.Length * 2); return b; } - VkBuffer bx = Up(x), bw = Up(w), bb = Up(bias), br = Up(res); - ulong outBytes = (ulong)M * (ulong)N * 2 + 4096; - VkBuffer bo = dev.NewStorageBuffer(outBytes, hostVisible: true, preferHost: true); - VkBuffer bo2 = dev.NewStorageBuffer(outBytes, hostVisible: true, preferHost: true); - var pipe = dev.NewPipeline(dev.NewShaderModule(spv), 6, 16, reqSg); - IntPtr set = dev.NewDescriptorSet(pipe.SetLayout); - VkBuffer[] binds = [bx, bw, bb, br, bo, bo2]; - for (uint i = 0; i < 6; i++) dev.BindBuffer(set, i, binds[i]); - uint gx = (uint)((M + tm - 1) / tm), gy = (uint)((N + tn - 1) / tn); - uint[] pc = [(uint)M, (uint)N, (uint)K, flags]; - IntPtr cmd = dev.NewCommandBuffer(); - IntPtr fence = dev.NewFence(); - var qci = new Vk.VkQueryPoolCreateInfo { SType = 11, QueryType = 2, QueryCount = 2 }; - Vk.Check(Vk.vkCreateQueryPool(dev.Device, &qci, null, out IntPtr qp), "qp"); - double best = double.MaxValue; - ulong* ts = stackalloc ulong[2]; - for (int r = 0; r < reps; r++) + /// Min GPU time (ms) of dispatches. The + /// operands are laid out for the call's own (M, N, K). + public double Time(VkPipeline pipe, int M, int N, int K, uint flags, uint tm, uint tn, int reps, uint groups = 0) { - var begin = new Vk.VkCommandBufferBeginInfo { SType = VkConst.StCommandBufferBeginInfo }; - Vk.Check(Vk.vkResetCommandBuffer(cmd, 0), "reset"); - Vk.Check(Vk.vkBeginCommandBuffer(cmd, &begin), "begin"); - Vk.vkCmdResetQueryPool(cmd, qp, 0, 2); - Vk.vkCmdBindPipeline(cmd, VkConst.BindPointCompute, pipe.Pipeline); - Vk.vkCmdBindDescriptorSets(cmd, VkConst.BindPointCompute, pipe.Layout, 0, 1, &set, 0, null); - fixed (uint* pp = pc) Vk.vkCmdPushConstants(cmd, pipe.Layout, VkConst.StageComputeShader, 0, 16, pp); - Vk.vkCmdWriteTimestamp(cmd, VkConst.PipelineStageBottomOfPipe, qp, 0); - Vk.vkCmdDispatch(cmd, gx, gy, 1); - Vk.vkCmdWriteTimestamp(cmd, VkConst.PipelineStageBottomOfPipe, qp, 1); - Vk.Check(Vk.vkEndCommandBuffer(cmd), "end"); - dev.Submit(cmd, fence); dev.WaitFence(fence); - Vk.vkGetQueryPoolResults(dev.Device, qp, 0, 2, 16, ts, 8, 1 | 2); - best = Math.Min(best, (ts[1] - ts[0]) * dev.TimestampPeriodNs / 1e6); + IntPtr set = _dev.NewDescriptorSet(pipe.SetLayout); + for (uint i = 0; i < 6; i++) _dev.BindBuffer(set, i, _binds[i]); + uint gx = groups != 0 ? groups : (uint)((M + tm - 1) / tm), gy = groups != 0 ? 1 : (uint)((N + tn - 1) / tn); + uint[] pc = [(uint)M, (uint)N, (uint)K, flags]; + double best = double.MaxValue; + ulong* ts = stackalloc ulong[2]; + for (int r = 0; r < reps; r++) + { + var begin = new Vk.VkCommandBufferBeginInfo { SType = VkConst.StCommandBufferBeginInfo }; + Vk.Check(Vk.vkResetCommandBuffer(_cmd, 0), "reset"); + Vk.Check(Vk.vkBeginCommandBuffer(_cmd, &begin), "begin"); + Vk.vkCmdResetQueryPool(_cmd, _qp, 0, 2); + Vk.vkCmdBindPipeline(_cmd, VkConst.BindPointCompute, pipe.Pipeline); + Vk.vkCmdBindDescriptorSets(_cmd, VkConst.BindPointCompute, pipe.Layout, 0, 1, &set, 0, null); + fixed (uint* pp = pc) Vk.vkCmdPushConstants(_cmd, pipe.Layout, VkConst.StageComputeShader, 0, 16, pp); + Vk.vkCmdWriteTimestamp(_cmd, VkConst.PipelineStageBottomOfPipe, _qp, 0); + Vk.vkCmdDispatch(_cmd, gx, gy, 1); + Vk.vkCmdWriteTimestamp(_cmd, VkConst.PipelineStageBottomOfPipe, _qp, 1); + Vk.Check(Vk.vkEndCommandBuffer(_cmd), "end"); + _dev.Submit(_cmd, _fence); _dev.WaitFence(_fence); + Vk.vkGetQueryPoolResults(_dev.Device, _qp, 0, 2, 16, ts, 8, 1 | 2); + best = Math.Min(best, (ts[1] - ts[0]) * _dev.TimestampPeriodNs / 1e6); + } + _dev.FreeDescriptorSet(set); + return best; } - Half* o = (Half*)bo.Map(); - bool hasBias = (flags & 1) != 0, hasRes = (flags & 2) != 0; - uint act = (flags >> 4) & 7; - double maxErr = 0; long bad = 0; - var badRows = new SortedDictionary(); - var badCols = new SortedDictionary(); - for (int m = 0; m < M; m++) - for (int n = 0; n < N; n++) - { - double acc = 0; - for (int k = 0; k < K; k++) acc += (double)x[(long)m * K + k] * (double)w[(long)n * K + k]; - if (hasBias) acc += (double)bias[n]; - if (hasRes) acc += (double)res[(long)m * N + n]; - acc = act switch - { - 1 => Math.Max(acc, 0), - 2 => 0.5 * acc * (1 + Erf(acc / Math.Sqrt(2))), - 3 => acc * Math.Clamp(acc * (double)BitConverter.UInt16BitsToHalf((ushort)(flags >> 16)) + 0.5, 0, 1), - 4 => 1 / (1 + Math.Exp(-acc)), - _ => acc, - }; - double g = (double)o[(long)m * N + n]; - double e = Math.Abs(g - acc); - if (double.IsNaN(g)) e = double.PositiveInfinity; - maxErr = Math.Max(maxErr, e); - if (e > 0.02 + 0.01 * Math.Abs(acc)) + /// Checks the last dispatch's output (the operands as laid out + /// for this M, N, K) against fp64. + public (double MaxErr, long Bad) Verify(int M, int N, int K, uint flags) + { + Half* o = (Half*)_binds[4].Map(); + bool hasBias = (flags & 1) != 0, hasRes = (flags & 2) != 0; + uint act = (flags >> 4) & 7; + double maxErr = 0; long bad = 0; + for (int m = 0; m < M; m++) + for (int n = 0; n < N; n++) { - bad++; - badRows[m % 64] = badRows.GetValueOrDefault(m % 64) + 1; - badCols[n % 64] = badCols.GetValueOrDefault(n % 64) + 1; + double acc = 0; + for (int k = 0; k < K; k++) acc += (double)_x[(long)m * K + k] * (double)_w[(long)n * K + k]; + if (hasBias) acc += (double)_bias[n]; + if (hasRes) acc += (double)_res[(long)m * N + n]; + acc = act switch + { + 1 => Math.Max(acc, 0), + 2 => 0.5 * acc * (1 + Erf(acc / Math.Sqrt(2))), + 3 => acc * Math.Clamp(acc * (double)BitConverter.UInt16BitsToHalf((ushort)(flags >> 16)) + 0.5, 0, 1), + 4 => 1 / (1 + Math.Exp(-acc)), + _ => acc, + }; + double g = (double)o[(long)m * N + n]; + double e = double.IsNaN(g) ? double.PositiveInfinity : Math.Abs(g - acc); + maxErr = Math.Max(maxErr, e); + if (e > 0.02 + 0.01 * Math.Abs(acc)) bad++; } - } - bo.Unmap(); - double tflops = 2.0 * M * N * K / (best * 1e-3) / 1e12; - Console.WriteLine($"gemm {shader} M={M} N={N} K={K} flags={flags} grid={gx}x{gy} reqSg={reqSg}: best={best:F4} ms ({tflops:F3} TFLOPS) maxErr={maxErr:G4} bad={bad}/{(long)M * N}"); - if (bad > 0) + _binds[4].Unmap(); + return (maxErr, bad); + } + + public void Dispose() { - Console.WriteLine(" bad by row%64: " + string.Join(" ", badRows.Select(kv => $"{kv.Key}:{kv.Value}"))); - Console.WriteLine(" bad by col%64: " + string.Join(" ", badCols.Select(kv => $"{kv.Key}:{kv.Value}"))); + Vk.vkDestroyQueryPool(_dev.Device, _qp, null); + _dev.DestroyFence(_fence); + foreach (var b in _binds) b.Free(); } - Vk.vkDestroyQueryPool(dev.Device, qp, null); - foreach (var b in binds) b.Free(); - return bad == 0 ? 0 : 3; } // Abramowitz-Stegun 7.1.26 (|err| < 1.5e-7) @@ -124,13 +243,4 @@ static double Erf(double v) double y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.Exp(-v * v); return s * y; } - - static byte[] LibSpv(string name) - { - using Stream s = typeof(VkDevice).Assembly.GetManifestResourceStream( - $"Sdcb.SimdPaddleOCR.Backends.Vulkan.Shaders.{name}.spv") ?? throw new FileNotFoundException(name); - byte[] b = new byte[s.Length]; - s.ReadExactly(b); - return b; - } } diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Sdcb.SimdPaddleOCR.AndroidBench.csproj b/test/Sdcb.SimdPaddleOCR.AndroidBench/Sdcb.SimdPaddleOCR.AndroidBench.csproj index 3e62c09..235fdec 100644 --- a/test/Sdcb.SimdPaddleOCR.AndroidBench/Sdcb.SimdPaddleOCR.AndroidBench.csproj +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/Sdcb.SimdPaddleOCR.AndroidBench.csproj @@ -36,5 +36,7 @@ + + diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak.comp b/test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak.comp new file mode 100644 index 0000000..83d299e --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak.comp @@ -0,0 +1,38 @@ +#version 450 +#extension GL_EXT_shader_explicit_arithmetic_types_float16 : require +#extension GL_EXT_control_flow_attributes : require + +// FMA peak probe: 8 independent vec4 chains per thread, fully unrolled +// inner body, 512 iterations: 2 * 4 * 8 * 512 = 32768 flop per thread. +// -DF16 runs the same on f16vec4. +// Rebuild: glslc -O --target-env=vulkan1.1 [-DF16] peak.comp -o peak[16].spv + +layout(local_size_x = 256) in; +layout(std430, binding = 0) buffer O { vec4 o[]; }; +layout(push_constant) uniform PC { float s; } p; + +#ifdef F16 +#define T f16vec4 +#define S(x) float16_t(x) +#else +#define T vec4 +#define S(x) (x) +#endif + +void main() +{ + T m = T(S(p.s)), c = T(S(1e-4)); + float b = float(gl_LocalInvocationID.x) * 1e-3; + T a0 = T(S(b)), a1 = T(S(b + 0.1)), a2 = T(S(b + 0.2)), a3 = T(S(b + 0.3)); + T a4 = T(S(b + 0.4)), a5 = T(S(b + 0.5)), a6 = T(S(b + 0.6)), a7 = T(S(b + 0.7)); + for (int it = 0; it < 64; it++) + { + [[unroll]] for (int u = 0; u < 8; u++) + { + a0 = fma(a0, m, c); a1 = fma(a1, m, c); a2 = fma(a2, m, c); a3 = fma(a3, m, c); + a4 = fma(a4, m, c); a5 = fma(a5, m, c); a6 = fma(a6, m, c); a7 = fma(a7, m, c); + } + } + T r = a0 + a1 + a2 + a3 + a4 + a5 + a6 + a7; + if (float(r.x) == 12345.0) o[gl_GlobalInvocationID.x] = vec4(r); +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak.spv b/test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak.spv new file mode 100644 index 0000000000000000000000000000000000000000..af1f022f045aaa47271007ddfbfba370dd404371 GIT binary patch literal 4136 zcmZvfTW?lH6oxmnv}(@>4RspdZi&}w}ewt7cOJb?VJ50FH!~=<3cwvZ*A;uVE zjHtAt!9x@jOiWDt37(3He?U?3Cva2~-)DDL^Jb+xU7fYovu0-RcV>4_o0bo>R!z&Q zwyL9ga#>YhJ=O9Gx2IgoLjA8(yIi(Mz45sQ78>#9dW<&3j75A*ZHuhCj+?4kwb37+%lm(z zxWCRW^e5hEG55##>tu}oS&7%GU8#{7yI=Rolj2`5kNNv#YgFH=mYC<;P@ZRjH`n7{ zt31!OLlw-^E+e;EafjMI8Cu+4;!gQoeTuu(QpXIWL#@II~ z?vrtGpF9sao`bP(RLi{Nxi?}oCdXXVq6ruE>(#=@$G!B+f1&r)?GbNMe0F|*G~zqz zcycV_yA{vP&MwCHEz~c@I~D8N74;1TbF*s~>Nl4>>mT)7sC zm2zkTdo-~xl|x_HBZ-|-jr3I2m|w8;H`G>#UT|7093GXL2)T4X+N z94#_GYaA^y|HU|3Wd56Rw8;E-<7kojIpb)N`5(s7BJ)3uqebR_8AprE&l^XJ%>Ooy z7MWi#jux3;G>#UTUow7f34Yl)TIBv$jH5;7SB;}Z=KmN+i_HHujux4>>*bF38!a;L zFpd_PcN#~F%)5-EMdsbc(IWF6<7kn2uW_`}eBXnkMdq9hf}=&|oGXH(MdqAIf}=&|oM(cM zFTptr1xJhM|BB8I9eVB=8E1#XGCy1Gt=WqDjBrFgXM>ZLdrdxhzWJQtTGhTTBhPm} za&MG8-}cD8DW5#w^T@p=pZpoiy)BQ(W3Sp8F{X6%lYSB`Q(qvF6lpn_tkQ7 z_JO@8BgfeXzoCAd`#+Q&m#veOV literal 0 HcmV?d00001 diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak16.spv b/test/Sdcb.SimdPaddleOCR.AndroidBench/Shaders/peak16.spv new file mode 100644 index 0000000000000000000000000000000000000000..2d86a121bead6ea5c5ba6fd581aae28960703d8f GIT binary patch literal 4348 zcmZvf*>6=<6vj6l?u8aw3YCVT?Jbx{0ydUgpg>yMsEn7RqH?W^nw1tmA5gmEj2O}tUZ-ev+^dzfxb{d{f@?xVs=qCrn4HYpPbG2 zzo)vdQ0x8Fi(Sq6vHwL1_WxRDO4Np+j@iUMDj<+@!tTSYb3OF#RAbIf^)7nCg5S#Ta*SwpvA z-wRY%6l|O`UZQbL?3-)nxagc4HTMpUV==c!!N&YvjWBdL=Y;1^%z19D)yq_0zjkfd z>VcxJjaa=}^{Mgksd~6Rf2zJ+wYK#(f3u2H#NJLaGhtMqwby>V`A+^fH(G;18wf7U~Xf;GfC8P-g$WsU;< zkZRV*+^p@)Yos%4=oajcdse>>(aS+`>@ zRgLE3Sngk*=dxa`|5oMb*59uj&4=}`kj^;_D6GG#9Nqc{m81Ev{vqk~vmUK~jdFDB zU#lF=hxLz0r=Rs}{p*#ZTmPtXG#}P~hjjW`@78~(a&+t8pd8JI^>2|*Kc5fl->Mwl z`fJM3d|3ZZ>Gbn?vi@Dl(XD^Cax@<|v=;YBV@)#tk=NH%<;-WDKCJbAw_{h7b1txl z0{cuF*9kii*kx&>nLQZTCF$HB`W^`EqBQP3Y=2;r(pXopeSuw&#+rrQ?-<`F6AC_W zu;&$A-+5Jh=iH}&^Ii0(J}-MU!rA^&4W~xOH}aWjdhy-;m~tJeShpO%Q_-a`eo(pb z$^YR`InH&*{%0J=v&auSj^B|Vah!V<`Lm95pCdovIBO*G&mG5$$iHwLFCzcaalDB9 zE64F7@~<7oi^#um94{jO)^WUu{5!|-BJv*`$BW2+bQ~`t|H*N@i2P^A@gnkH9LI~u ze{~!$BLB^CyomgF$MGU^y@F1yZ@h@S$#J}hyxDQQh`i)DUPNAY94{iD?l@jVKErXm zh`hydyoh{(<9HGILdWqU@j^jn-y^iBWB_IXcgF(_U4M&hy-~apmYd!%cflIXchnT{@SN#@7|-Y`izN zXnaF~K9S#t-ptx*jeqGE%-b5-co%}br9k6dhTU i#~;(#oYVNRg5PBA3g(XJcxcSSPdg6dzq%7civIxVj0TJV literal 0 HcmV?d00001 diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/gprof.ps1 b/test/Sdcb.SimdPaddleOCR.AndroidBench/gprof.ps1 new file mode 100644 index 0000000..8afdbed --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/gprof.ps1 @@ -0,0 +1,21 @@ +# Pure-GPU profile on the phone over the GpuBench gprof.ps1 case list +# (per-dispatch timestamps, min over reps). Logs land in bench-out/android/gp--*.log. +param([string]$tag = "x", [string[]]$cases = @( + "det medium 960 960", "det medium 640 960", "rec medium 8 480", "rec medium 1 320", + "det small 960 960", "rec small 8 480", "det tiny 960 960", "rec tiny 8 480"), + [int]$reps = 10, [switch]$all, [string]$Env = "") +$here = $PSScriptRoot +$out = (Resolve-Path "$here\..\..").Path + "\bench-out\android" +$envs = "SIMD_OCR_GPU_PROF=1" + $(if ($all) { ";SIMD_OCR_GPU_PROF_ALL=1" }) + $(if ($Env) { ";$Env" }) +foreach ($c in $cases) { + $k, $m, $a, $b = $c -split " " + $mode = if ($k -eq "det") { "--detprof" } else { "--recprof" } + $id = "gp-$tag-$k-$m-$a-$b" + & "$here\run.ps1" -Bench "$mode $m $a $b $reps" -Env $envs -Run $id | Out-Null + $log = "$out\$id.log" + $t = (Select-String -Path $log -Pattern "GPU profile \((\d+) dispatches, ([\d.]+) ms").Matches + $w = (Select-String -Path $log -Pattern "best wall ([\d.]+)").Matches + $th = (Select-String -Path $log -Pattern "thermal=(\d+)").Matches + "{0,-22} gpu={1,8} ms disp={2,4} wall={3} thermal={4}" -f $c, $t[0].Groups[2].Value, $t[0].Groups[1].Value, + $(if ($w) { $w[0].Groups[1].Value } else { "?" }), $(if ($th) { $th[0].Groups[1].Value } else { "?" }) +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 b/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 index c187b62..39cbaa3 100644 --- a/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 @@ -75,7 +75,15 @@ while (((Get-Date) - $t0).TotalSeconds -lt $TimeoutSec) { if ($done -match "^\d+$") { $result = [int]$done; break } $procId = (& $Adb shell "pidof $pkg") -join "" if ($procId) { $started = $true } - elseif ($started -or ((Get-Date) - $t0).TotalSeconds -gt 30) { break } + elseif ($started -or ((Get-Date) - $t0).TotalSeconds -gt 30) { + # the app writes .done and then kills itself: look once more + Start-Sleep -Seconds 1 + $done = (& $Adb shell "cat $remote/out/$Run.done 2>/dev/null") -join "" + $lines = @(& $Adb shell "cat $remote/out/$Run.log 2>/dev/null") + for ($i = $seen; $i -lt $lines.Count; $i++) { Write-Output $lines[$i] } + if ($done -match "^\d+$") { $result = [int]$done } + break + } } & $Adb pull "$remote/out/." "$local" 2>&1 | Out-Null From e228638b9d5d4d35f27bffdda062238d1573302b Mon Sep 17 00:00:00 2001 From: sdcb Date: Mon, 28 Sep 2026 22:57:00 +0800 Subject: [PATCH 07/12] perf(vulkan): fp16 shared tiles with BK 8 in the direct-load gemm_nc On Adreno 750 the direct-load GEMM ran at ~0.44 TFLOPS against a measured 2.0 TFLOPS fp32 FMA peak. Sweeping the kernel on the eleven hottest shapes (sum of min GPU ms, variants interleaved in one run): 64x64 BK16 fp32 tiles (was) 109.5 BK 8 83.1 128x128, 8x4 per thread, 512 thr 84.1 BK 8 + fp16 tiles (kept) 76.2 0.64 TFLOPS same with 128x64 / 64x128 blocks 74.6 / 75.0 (noise; 64x128 loses half a block on N=64) BK 4 / BK 32 / 32x32 4x4 83.2 / 200.6 / 148.1 no shared memory, per-thread f16vec4 rows: 2-4x slower, and the fully unrolled 8x8 form is miscomputed by the driver like the prefetch was The DIRECT build now keeps the A/B tiles as fp16 in shared memory (they come from fp16 memory, so this is exact) with BK 8; the staged tail gets its own fp32 array so accumulators are never narrowed before bias, residual and activation. Each output still takes one fma per k in increasing K, so results are bit-identical: e2e tiny and medium match the previous build 100/100 (hash, detected, texts). gemm_nc.spv and gemm_nc_s.spv (UHD 770, 8-lane pin) rebuild byte-identical. GT5 Pro, same-period pure GPU (gprof, min ms, two alternating rounds, both rounds identical to 0.1 ms): medium DET 960 381 -> 311, 640x960 270 -> 221, medium REC 8x480 359 -> 270, 1x320 40.1 -> 34.7, small DET 64.5 -> 52.7, small REC 72.7 -> 54.6, tiny DET 28.0 -> 23.7, tiny REC 15.9 -> 12.3. --- .../Backends/Vulkan/Shaders/gemm_nc.comp | 27 +++++++++++++----- .../Backends/Vulkan/Shaders/gemm_nc_d.spv | Bin 10088 -> 10136 bytes .../Backends/Vulkan/Shaders/gemm_nc_ds.spv | Bin 8152 -> 8136 bytes 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc.comp b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc.comp index 3a92891..9aa76e0 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc.comp +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc.comp @@ -17,7 +17,9 @@ // // -DDIRECT (devices that cannot pin a narrow subgroup, e.g. Adreno wave64): // each tile goes straight from global memory into shared memory, no -// register prefetch. Adreno's compiler miscomputes the prefetch form. +// register prefetch (Adreno's compiler miscomputes the prefetch form); +// tiles stay fp16 in shared memory with BK 8 (Adreno 750: 30% faster than +// fp32 tiles with BK 16), the tail stages through its own fp32 array. // // flags: bit0 hasBias, bit1 hasResidual, bit3 dual output, bits[4:7] activation // act: 0=none 1=relu 2=gelu 3=hardswish 4=sigmoid @@ -29,6 +31,9 @@ #ifndef BN #define BN 64u #endif +#if defined(DIRECT) && !defined(BK) +#define BK 8u +#endif #ifndef BK #define BK 16u #endif @@ -59,7 +64,15 @@ layout(push_constant) uniform PC { uint mDim; uint nDim; uint kDim; uint flags; // As = S[0, BK*BM), Bs = S[BK*BM, ...); after the K loop each thread stages // one accumulator row at S[tid*TN, +TN), so BK*(BM+BN) >= NTHR*TN +#ifdef DIRECT +shared float16_t S[BK * (BM + BN)]; +shared float St[NTHR * TN]; +#define LDS(v) float(v) +#else shared float S[BK * (BM + BN)]; +#define St S +#define LDS(v) (v) +#endif #define As(i) S[i] #define Bs(i) S[BK * BM + (i)] @@ -123,7 +136,7 @@ void main() uint r = e / AK, cv = e - r * AK; #ifdef DIRECT uint gm = m0 + r, gk = k0 + cv * 4u; - vec4 f = vec4((gm < p.mDim && gk < p.kDim) ? x[gm * kv + (gk >> 2u)] : z); + f16vec4 f = (gm < p.mDim && gk < p.kDim) ? x[gm * kv + (gk >> 2u)] : z; #else vec4 f = vec4(ra[t]); #endif @@ -141,7 +154,7 @@ void main() uint r = e / AK, cv = e - r * AK; #ifdef DIRECT uint gn = n0 + r, gk = k0 + cv * 4u; - vec4 f = vec4((gn < p.nDim && gk < p.kDim) ? w[gn * kv + (gk >> 2u)] : z); + f16vec4 f = (gn < p.nDim && gk < p.kDim) ? w[gn * kv + (gk >> 2u)] : z; #else vec4 f = vec4(rb[t]); #endif @@ -176,8 +189,8 @@ void main() { float a[TM]; float b[TN]; - for (uint i = 0u; i < TM; i++) a[i] = As(kk * BM + ty * TM + i); - for (uint j = 0u; j < TN; j++) b[j] = Bs(kk * BN + tx * TN + j); + for (uint i = 0u; i < TM; i++) a[i] = LDS(As(kk * BM + ty * TM + i)); + for (uint j = 0u; j < TN; j++) b[j] = LDS(Bs(kk * BN + tx * TN + j)); for (uint i = 0u; i < TM; i++) for (uint j = 0u; j < TN; j++) acc[i * TN + j] = fma(a[i], b[j], acc[i * TN + j]); @@ -220,14 +233,14 @@ void main() // copies, which push the accumulators into spills for (uint i = 0u; i < TM; i++) { - for (uint j = 0u; j < TN; j++) S[tid * TN + j] = acc[i * TN + j]; + for (uint j = 0u; j < TN; j++) St[tid * TN + j] = acc[i * TN + j]; uint gm = mBase + i; if (gm >= p.mDim) continue; [[dont_unroll]] for (uint j = 0u; j < TN; j++) { uint gn = nBase + j; if (gn >= p.nDim) continue; - float v = S[tid * TN + j]; + float v = St[tid * TN + j]; if (hasBias) v += float(bias[gn]); if (hasRes) v += float(res[gm * p.nDim + gn]); o[gm * p.nDim + gn] = actf(v, act, p.flags); diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc_d.spv b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/Shaders/gemm_nc_d.spv index 9f6f4fd1ecdf9e4facbe9062a76a24f7fbfb5562..0a1a560bce0d62d4898d05f4a96f9d04e4a4dcd5 100644 GIT binary patch literal 10136 zcmZvi35=H26^4J90j!RS6+}^SghnI~N?oW+1edhfU`t(SO=`q(j8!Wl#l2ck76F&K zRRa|n6>Wv0U~65f5{1OHwJ1@mU0kSjt+sBch)AF3oBK|_O!|eB^Pcydd(XM&-tYeZ zKcT7TkUov39*u1p+cs7=HL7QaM$bkMkorqSAH73^nBEmX?xC*l)(aHm<>CsAZQR~vS6iG7kQc0E@+G07#~lX!=jOS_)yEG9OW^IPW>fcQva z#XiXuyPj(*G07!1m&44ZJ=h6&v@gc4=eh)&e^oBAPjbbs=bBDTa*55=ggCeMU?q>0?Rk_4IyCCjY?0T-NiP7&|Vsm+BVh;xUXy1yzp6fPj{#CieKFJlko@)s) z$t5M?Uxs#W`Gbo+vvB%n73`SIHxKOHk$CSe=ZOD0@S(^cDqDbd zY+ampA)I%ue&^BtMd6&sb>5744vb$^e9p5Q_FZV#Z7%&w(Vh?ajK2qLU9Nj2+Vf!_ z^oxIs_>5qr{`(NQx@1jj;b$P3`!%r7?sU@o)bWGB-j~|ev;l3sa;{O^`%+H-=7JrY zJWUo%k*={BY@Kq(_d;90yz^=IE}Z_p1v|#NPQp%IZD4KVPDWdUobzaVm&&D%DcCkY z_CoBf5bIiow)dU`=leCby_Zjawtzijv3oLCdT*X)Y{xj?{$S@br}K?2vGUfZ{q@4> zKfGYaxK8hV*KGg2*R_p18*MH*=h5~am(xG3;PlHoJ@a^HYa92|5+~<8+TPL5V}9@F zjPw50cHC7ZM$U2C-oF{=U7K;D!syON`coM1V(0XIS%$a=+V0_Uq;9V%_F;^gMciz}yP+L9gfZ4L2a%sgjQ0G( zc{b)0?y4G>xvqip-l)%YEw=ouGVXe?oa@ZEpMxFenp4A#VEK%@2`ra!-W|6f-Y?!M z8MnCbkC%KM*mC2Ljr8g^v^`UApfj#-CXKXa`?vzRz0$p^&zE0;^_%x&`m?0?QH{^A z^&2nmykgg7&zB?ahvUa^rnLS1no(*qmp<>bdTq|7eLvbc)v0wB@$J~gY-TSXLa$&x zKMPkO`dzQJK8V)uW3BohK;)fQY@Wx8txlfBC66&9$+HHXJWqi2+f(zbM(g)6kN(FI zdGm;~kADI?)_$fBPl7#j`jX=*u>4x&6MDA}?c@CVo<`)HUz~IKEZA|@mYgqu<>f9b zz5grNIDM((MX=}7*o<8dHg+nKxPR(r4f@<`&*JNdXYn;8XYq}~XH9Qnn?v3;y^Z#9 zP5RzK=q-r8dQNk@2K_7PsWH}ZA7Xu} zXKS$g;9Z#9EpYPg*U{{Q_wM%C&g0|W`1!d5;%BPyzK7bzWuAWE^~8JsBwv3x`T98> zfGuCow+puUP+j>3Vwlf(^JyEGe7l3!6K@}rZxEb(Jzp!fd_CV_Z1b^uUHOKB9&+(+X|oWAsFJlM}Y zV^i-0u(4C&6Zc*HtU+JyqhrC|NA@`P(Q$C{S<^(YIpn=7zK`~CP5Qov$hjtQ#{B?n z&fG_dQRhB70qh+1!g$AXlzO4IHu(ftDIQP+M*w$em(~Hxw zjn$X?=nSymg}smT=RW!oSYJJ-xpN;ar>DkPhxd{7rJiYE_o03tor^8+ex;uCz*7)? zxuY%sJGW2XsSDwHBgXqqX&aY$E(W{5^*ibkZ29`xosKPEuVDtZHRwwXmw~MT)m6hx z3~Mmn8nlf|4RgTOP(RC;W6RfTn1?N2uVFs6HRwwXSAwmfewSSZXAQ<%gSK(0;To_t z)bFxuvE}PET#qeZui*x4YtWY(ZUkF{+mXGv3CFEzywYf{`ezRpVmrsp#jnnNcPr!DiE+#(WHFNW?>4Z0zmI$0-BSFR)w`>I z5h8D`V)HC1wmNz4D0z(eJ#q(WFL`}GLgb*a;@JBe>+ZeyN79!1ix)nNVEmsQ12c(pJ3jhA;`aeDIvxO>0;05_kp z^?v;k&RBg}$CF@rbBFsgwr5>mdbk#B5A~&oPl4sr!>7S=>ESxCzZsp|9%>ttJoZ3e zdYGQMMtk@i+O?|F!(S5L&bq1?_!p4$@b6&#>EZLmpC0P}3nK5lV)MLMY<2Q1D|w9h z1bGQbo>#&8ljr5)Pagfo%bQ1>y?PCt8qT4??+(%HTEevoWmIR%sBn2)n1q{dzRk0R(t7wxMp?6 zYy`WGjLE*b4~|(}#;7x96WBAIF}de%f&YK^pubhktIiyoiS@gU`(&Q{CVmjx{`lJq zy85oOg;@L30@cG06(rvkn!s}Y&FTrp8Pf|a@87M$Z4EZg&)H?f`P*t6Z25L@;sR8eKXLuxb4ng# zzJYudNuK?{`jcl=@h6Xd9}nfD<5u>Qn+3+z5xQ_l5x?CyV4I0{bw z?s7K24R)-&->)a2ef$ou?>mT`eG(tkV$eB;Ti?y0`qxn~sK{FA}S@BMHlB4<9azdih0^f`!spZ2$h^*fi} zUCu4OeD0oyEq5vV>KVHLY(9C{lDXCHkume=p)sz{c=KA@PtZR0#yjaEM9$j8>BUU2 zXUCpqe`bT_tug-6 z_1yp`Z+)(H3)*KBxE;L~+jXj!mUrZ7a4*C=-7(LBee93Eb%>nf#4V-2&w#m(8+#+x zIRNci-M?q?>33b$;8^EK&ga4A^z*@->%l(e)b|1+=Q#1~vhRNbk0;K2;r;=qJpulK zvZg-R^44@0r@`~E6`cG)@UBQp;kSl!9nFYiGH(lfE1dr9*|y+nEP8IKX}iMd3-@L4 zaK_4I?2h1JaQc#?AK3Bw64xI*lsJ8f+X-x(zUfrzdIo~6Eit=+!)0x|!?`wV)}Pox zVCU37v#eteuyOhl*9x|8`cmIZ^vF1UiF+B&z0sF5@CsPIeg@proPk%to&kTWdImlK z`*;TQ{R@%v42bhx<#limWD>Eivkm=*9NIIi-`}tH=uNObpVazqY|o3ntmz%Fyk{%l z8Q;ZrPWMs2XUTPEp7-FK$GEKdeXx9e&8{J9{s`=v{ri+_{v7P%n)Pi!K7mK=5*8C1Q*^ zd$S*NcE2Y^!?~vVJ<*0OU%w|##+L7XPn-g0&%GxclU%2QTjBI)AASgSAM|A(PAiPnrhvUC^d;_WuyOiwcFqA?TVl=whs$|7A1>!fe_}5H zJE#8K6BmMAo4&+d1h#MbQr`h!FHW literal 10088 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 literal 8152 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 AdH?_b From 8961dc27133ec2651b3f6ecdea0fce6288ad0078 Mon Sep 17 00:00:00 2001 From: sdcb Date: Mon, 28 Sep 2026 23:08:27 +0800 Subject: [PATCH 08/12] perf(vulkan): two-phase spatial mean at any size on wide no-coopmat parts After the GEMM, medium REC's third hotspot was reduce_hw (29 of 270 ms): below 4096 pixels the spatial mean ran one channel per workgroup, which on Adreno reads 2 bytes at a C*2 stride (the c512 means of the 8x480 batch took 7.6 ms each). The lite sg32 parts already take the two-phase reduce_hw4 / reduce_hw4b pair at any size for the same reason; the no-coopmat tier now does too when it cannot pin a narrow subgroup (_ncWide). UHD 770 (8-lane pin) keeps the old gate. GT5 Pro, same-period pure GPU (min ms, two alternating rounds, identical to 0.1 ms): medium REC 8x480 269.7 -> 243.3, medium DET 960 324.4 -> 317.0, 640x960 229.8 -> 226.1; small / tiny unchanged. e2e tiny, small, medium match the previous build 100/100 (hash, detected, texts). Tried and dropped: the shared-tile conv_dw4t for 9x9 depthwise (sg32 takes it up to 9) is slower here, medium DET 303.7 -> 317.0, small DET 52.7 -> 55.3. --- src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs index fe1d272..7a4991a 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs @@ -86,6 +86,9 @@ internal void Release() private readonly bool _nocm; // gemm_nc built with the unrolled no-act / relu / hardswish, single-output tail private readonly VkPipeline? _pGemmNcS; + // no-coopmat tier on a device that cannot pin a narrow subgroup (Adreno + // wave64): direct-load GEMM plus the routing measured on Adreno 750 + private readonly bool _ncWide; // fp16 copies of constant tensors (weights, biases, scalars), lazy private readonly Dictionary<(int, int, int, int), VkBuffer> _constF16 = new(); @@ -201,6 +204,7 @@ private GpuGraphModel(VkDevice dev, CompiledModel compiled) // 8-lane pin (wave64 Adreno) the register-prefetch form is // miscompiled there; the direct-load build is exact and faster. bool sg8 = dev.ComputeSubgroupSize && dev.SubgroupMin <= 8 && dev.SubgroupMax >= 8; + _ncWide = !sg8; _pConv1x1 = sg8 ? Pipe("gemm_nc", 6, 16, 8u) : Pipe("gemm_nc_d", 6, 16); _pGemmNcS = sg8 ? Pipe("gemm_nc_s", 6, 16, 8u) : Pipe("gemm_nc_ds", 6, 16); _pConv1x1N64 = _pConv1x1; @@ -1730,9 +1734,9 @@ int ConstIn(NodeRecord nd, int other) seCtr += nb; // one ticket counter per batch break; } - // lite parts take it at any size: reduce_hw's one-channel - // workgroups read 2 bytes at a C*2 stride - if (c % 4 == 0 && 256 % cv4 == 0 && (hw >= 4096 || _lite)) + // lite and wide no-coopmat parts take it at any size: + // reduce_hw's one-channel workgroups read 2 bytes at a C*2 stride + if (c % 4 == 0 && 256 % cv4 == 0 && (hw >= 4096 || _lite || _ncWide)) { // two-phase: S pixel partitions → fp32 partials → mean int s = PartSplits(hw, c); From 915648298b4d6c21d7996d89a5e3ab71e131ae3e Mon Sep 17 00:00:00 2001 From: sdcb Date: Mon, 28 Sep 2026 23:23:04 +0800 Subject: [PATCH 09/12] perf(vulkan): direct kxk conv at every K on wide no-coopmat parts Dense kxk convs switch from the direct convk_dot to im2col + GEMM once Kp > 1024 (SIMD_OCR_CONVD_KMAX); on UHD 770 lowering that cap lost. On Adreno 750 the direct kernel keeps up with the tuned GEMM (57600x2304x64 at ~0.56 TFLOPS) and skips writing and re-reading the im2col matrix, so the wide no-coopmat tier now defaults to direct at every K (the env var still overrides). Every dense conv in these models has Kp <= 2304. GT5 Pro, same-period pure GPU (min ms, two rounds each) with the cap at 256 / 512 / 1024 / 2304: medium DET 960 315.2 / 312.3 / 304.0 / 299.3, small DET 64.6 / 60.7 / 52.8 / 52.8, tiny DET 32.8 / 29.9 / 23.7 / 23.7; REC unchanged. With this build: medium DET 960 299.7, 640x960 216.9 -> 206.2. e2e medium matches the previous build 100/100. The dot -> GEMM cut for K <= 128 pointwise convs was re-swept too (M*cout > 2^12 / 2^14 / 2^16 / 2^18 / 2^20): 2^12..2^16 within 0.3 ms, 2^18 and up cost tiny DET 23.7 -> 26.5 ms. It stays at 2^16. --- src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs index 7a4991a..26f2b24 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs @@ -1421,10 +1421,12 @@ bool Absorbable(int cn, int srcPhys) // the im2col matrix is one binding: past // maxStorageBufferRange (Adreno: 128 MB) go direct bool im2colFits = (long)M * Kp * 2 <= (long)_dev.MaxStorageRange; + // wide no-coopmat parts: direct at every K measured + // (Kp <= 2304 in these models), im2col+GEMM never won if (convkCm || (cout % 4 == 0 && scalarBias == 0 && cinIn % 4 == 0 && (nb > 1 || !im2colFits || Kp <= (Environment.GetEnvironmentVariable( "SIMD_OCR_CONVD_KMAX") is string km - ? int.Parse(km) : 1024)) + ? int.Parse(km) : _ncWide ? int.MaxValue : 1024)) && Environment.GetEnvironmentVariable("SIMD_OCR_NODCONV") == null)) { // stem conv on the fp32 NCHW input: skip nchw2nhwc From b294c2ac2f5e20103459568606702f5816ff2dfa Mon Sep 17 00:00:00 2001 From: sdcb Date: Tue, 29 Sep 2026 01:01:11 +0800 Subject: [PATCH 10/12] test(android): alternating CPU/Vulkan e2e script; stop the old process before a launch ab.ps1 runs each model round by round with the backends alternating and a cool-down in between, and prints median / last-50 median, peak working set, thermal status and accuracy per run. run.ps1 now force-stops the app and waits for the process to go away before launching: an intent delivered to an instance that is still exiting was swallowed and the run reported as dead. --- test/Sdcb.SimdPaddleOCR.AndroidBench/ab.ps1 | 26 ++++++++++++++++++++ test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 | 4 +++ 2 files changed, 30 insertions(+) create mode 100644 test/Sdcb.SimdPaddleOCR.AndroidBench/ab.ps1 diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/ab.ps1 b/test/Sdcb.SimdPaddleOCR.AndroidBench/ab.ps1 new file mode 100644 index 0000000..9ca434f --- /dev/null +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/ab.ps1 @@ -0,0 +1,26 @@ +# End-to-end A/B on the phone: for each model, backends alternate round by +# round (cpu, vulkan, cpu, vulkan, ...) with a cool-down between runs. Prints +# median ms (all / last 50 images), thermal status and accuracy per run. +# .\ab.ps1 -tag final -models tiny,small,medium -rounds 3 +param([string]$tag = "ab", [string[]]$models = @("tiny", "small", "medium"), + [string[]]$backends = @("cpu", "vulkan"), [int]$rounds = 3, [int]$cool = 60, [string]$Env = "") +$here = $PSScriptRoot +$out = (Resolve-Path "$here\..\..").Path + "\bench-out\android" +foreach ($m in $models) { + for ($r = 1; $r -le $rounds; $r++) { + foreach ($b in $backends) { + $id = "$tag-$m-$b-r$r" + Start-Sleep -Seconds $cool + & "$here\run.ps1" -Bench "--e2e --model $m --backend $b --out $id.json" -Env $Env -Run $id -TimeoutSec 3600 | Out-Null + $log = "$out\$id.log" + $st = (Select-String -Path $log -Pattern "steady: all-med ([\d.]+) last50-med ([\d.]+) ws-peak (\d+)MB thermal=(-?\d+)").Matches + $ac = (Select-String -Path $log -Pattern "exact_lines=(\d+)/\d+ .*CER=([\d.]+)%").Matches + $gpu = (Select-String -Path $log -Pattern "usesGpu=(\w+)").Matches + if ($st) { + "{0,-7} {1,-7} r{2} med {3,7} last50 {4,7} ws {5,5}MB thermal {6} lines {7} CER {8}% gpu={9}" -f $m, $b, $r, + $st[0].Groups[1].Value, $st[0].Groups[2].Value, $st[0].Groups[3].Value, $st[0].Groups[4].Value, + $ac[0].Groups[1].Value, $ac[0].Groups[2].Value, $gpu[0].Groups[1].Value + } else { "{0,-7} {1,-7} r{2} FAILED (see $log)" -f $m, $b, $r } + } + } +} diff --git a/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 b/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 index 39cbaa3..68df8e1 100644 --- a/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 +++ b/test/Sdcb.SimdPaddleOCR.AndroidBench/run.ps1 @@ -39,6 +39,10 @@ if ($Install) { } function Start-Bench([string]$id, [string]$argv, [string]$envs) { + # a previous run may still be exiting: an intent delivered to it would be + # swallowed by the dying activity instead of starting a fresh process + & $Adb shell "am force-stop $pkg" + for ($i = 0; $i -lt 20 -and ((& $Adb shell "pidof $pkg") -join ""); $i++) { Start-Sleep -Milliseconds 500 } & $Adb shell "rm -f $remote/out/$id.done $remote/out/$id.log" $cmd = "am start -S -n $pkg/$pkg.MainActivity --es run '$id' --es args '$argv'" if ($envs) { $cmd += " --es env '$envs'" } From a3185757e41bdc876fb2f0cdc75a7a80260bb7e7 Mon Sep 17 00:00:00 2001 From: sdcb Date: Tue, 29 Sep 2026 01:01:11 +0800 Subject: [PATCH 11/12] vulkan: Auto takes the no-coopmat tier where subgroups cannot go below 64 Auto skipped the no-coopmat tier everywhere because it trails the CPU on UHD 770. On Adreno 750 (no cooperative matrix, compute subgroups 64-128) the tuned tier beats the phone's own CPU on all three models, in every alternating round. GpuBackend.UsesGpu (and with it IsGpuBackend, i.e. the 16-line REC batches) now also says yes for Auto when the device reports SubgroupMin >= 64. UHD 770 (pins 8 lanes) and every other no-coopmat device keep Auto on the CPU; coopmat devices are unchanged. SIMD_OCR_BACKEND=cpu still forces the CPU. GT5 Pro, alternating rounds (median ms, cpu vs vulkan; thermal status 2-3, medium at 3 throughout): tiny 219 / 203 / 276 vs 156 / 170 / 155 / 152 small 867 / 785 / 812 vs 251 / 247 / 246 / 255 medium 3074 / 3294 / 3369 vs 1466 / 1458 / 1467 Accuracy cpu vs vulkan: 742 / 2.37% vs 743 / 2.40%, 950 / 0.41% vs 949 / 0.42%, 1004 / 0.14% vs 1003 / 0.15%. --backend auto: usesGpu=True, 100/100 identical to --backend vulkan; auto + SIMD_OCR_BACKEND=cpu: 100/100 identical to --backend cpu. --- src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuBackend.cs | 10 ++++++---- src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuBackend.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuBackend.cs index cfe9d45..ab1b7c1 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuBackend.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuBackend.cs @@ -50,12 +50,14 @@ private static bool IsVulkanExplicit(OcrBackend backend) => /// Whether a session for this option runs on the GPU. Without an /// fp16 cooperative matrix of the cm shaders' shape () - /// only an explicit Vulkan choice gets the no-coopmat GEMM tier: it trails - /// the CPU on every model measured (docs/vulkan-uhd770.md), so Auto stays - /// on the CPU there. + /// Auto takes the no-coopmat GEMM tier only where compute subgroups cannot + /// go below 64 lanes: there (Adreno 750) it beats the CPU on every model + /// measured (docs/vulkan-8gen3.md), while on UHD 770, which pins 8 lanes, + /// it trails the CPU (docs/vulkan-uhd770.md). Other no-coopmat devices + /// only get the GPU for an explicit Vulkan choice. internal static bool UsesGpu(OcrBackend backend) => IsVulkanSelected(backend) && TryGetDevice() is { } dev - && (dev.CoopGemm || IsVulkanExplicit(backend)); + && (dev.CoopGemm || dev.SubgroupMin >= 64 || IsVulkanExplicit(backend)); /// Creates a session on the resolved backend; CPU on any GPU failure. internal static IOcrSession CreateSession(CompiledModel compiled, OcrBackend backend) diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs index 26f2b24..bb3f296 100644 --- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs +++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs @@ -81,8 +81,8 @@ internal void Release() private readonly bool _lite; // No coopmat of the selected cm set's shape (VkDevice.CoopGemm): // subgroup-free gemm_nc instead of every cm pipe - // (Intel's compiler aborts the process on those shaders). Sessions only - // reach this for an explicit OcrBackend.Vulkan — GpuBackend.UsesGpu. + // (Intel's compiler aborts the process on those shaders). Auto sessions + // reach this only on wave64-minimum devices — GpuBackend.UsesGpu. private readonly bool _nocm; // gemm_nc built with the unrolled no-act / relu / hardswish, single-output tail private readonly VkPipeline? _pGemmNcS; From 23e53e279be2161606e80be08ed3b79536bf3928 Mon Sep 17 00:00:00 2001 From: sdcb Date: Tue, 29 Sep 2026 01:16:40 +0800 Subject: [PATCH 12/12] docs: Snapdragon 8 Gen 3 / Adreno 750 Vulkan report docs/vulkan-8gen3.md: the phone and driver, the --caps probe (sgRange 64-128, no cooperative matrix, 32 KB shared memory, 128 MB binding range, timestamps usable, 2.09 TFLOPS fp32 FMA), the no-coopmat route it takes, the three Adreno-only correctness bugs and how they were bisected, alternating CPU / Vulkan end to end on all three models with thermal status, accuracy and per-image parity, pure GPU before / after tuning and the hotspots, what was tuned, re-swept, dropped and left, the Auto rule, and the 3080 Ti regression. perf.md links it; the README rows now name the Vulkan loader per OS and the Android host, and say the desktop path is unchanged. --- README.md | 3 +- README_EN.md | 3 +- docs/perf.md | 3 +- docs/vulkan-8gen3.md | 145 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 docs/vulkan-8gen3.md diff --git a/README.md b/README.md index 5636ca0..65d24dc 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,8 @@ ImageSharp 默认分配器会把像素拆成 4MB 块,大图上拿不到一整 | CI 架构 | Windows x64 / x86 / ARM64,Linux x64 / ARM64,macOS x64 / ARM64 | | SIMD | .NET 10 运行时探测 AVX → AVX2 → AVX-512 / VNNI;无对应指令集或 ARM 时用 Vector/标量 | | 输入 | 交错像素内存(默认 BGR24,也可 RGB24 / BGRA32 / RGBA32);无图片路径、文件或图片库 API | -| 设备 | CPU only,无 GPU | +| 设备 | 默认 CPU;`net10.0` 另有可选 Vulkan 后端(`OcrBackend`),直连系统加载器:Windows `vulkan-1.dll`、Linux `libvulkan.so.1`、Android `libvulkan.so`;`netstandard2.0` 只有 CPU | +| 安卓 | 目前通过开发用宿主 `test/Sdcb.SimdPaddleOCR.AndroidBench`(`net10.0-android`,引用 `net10.0` 库,adb 驱动)在骁龙 8 Gen 3 上跑 CPU 与 Vulkan,见 [`docs/vulkan-8gen3.md`](docs/vulkan-8gen3.md);桌面的 Vulkan 路由和 shader 没有变 | | NativeAOT | 裁剪发布时请保留核心程序集和所用模型程序集 | ## 许可证与第三方组件 diff --git a/README_EN.md b/README_EN.md index 3afd1c1..358692b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -202,7 +202,8 @@ ImageSharp's default allocator splits pixels into 4MB chunks, so large images do | CI architectures | Windows x64 / x86 / ARM64, Linux x64 / ARM64, macOS x64 / ARM64 | | SIMD | .NET 10 probes AVX → AVX2 → AVX-512 / VNNI at runtime; Vector/scalar when those ISAs are missing or on ARM | | Input | Interleaved pixels (BGR24 by default; RGB24 / BGRA32 / RGBA32 also accepted); no image path, file, or image-library API | -| Device | CPU only, no GPU | +| Device | CPU by default; `net10.0` also has an optional Vulkan backend (`OcrBackend`) loading the system loader directly: `vulkan-1.dll` on Windows, `libvulkan.so.1` on Linux, `libvulkan.so` on Android; `netstandard2.0` is CPU only | +| Android | Currently run through the dev host `test/Sdcb.SimdPaddleOCR.AndroidBench` (`net10.0-android`, references the `net10.0` library, driven over adb) on a Snapdragon 8 Gen 3, CPU and Vulkan; see [`docs/vulkan-8gen3.md`](docs/vulkan-8gen3.md). Desktop Vulkan routing and shaders are unchanged | | NativeAOT | Keep the core assembly and the model assemblies you use when publishing trimmed | ## License and third-party components diff --git a/docs/perf.md b/docs/perf.md index 6881d20..ed14ac0 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -162,7 +162,7 @@ x64 / ARM64 本库 peak 大约少 **300 MB**。c 仍然略省,但更慢。Open ### Vulkan GPU(RTX 3080 Ti,`8d62a35`) -同机同轮:HOME-MAIN 5800X + RTX 3080 Ti(驱动 581.80),`.NET 10.0.11`,`--engine sharp|vulkan --workers 4 --benchmark-kind simd --warmup 1`,同一 `dataset/` 100 张变尺寸图(对 GPU 最不利的逐图新 shape)。墙钟 n=99。**CPU 列是同轮重测**,不要拿上表 1.4.2 的 `97c1448` mean 硬接(那次 tiny 63.1 / small 200 / medium 585)。B580 另一台机、另一份尺子,见 [vulkan-b580.md](vulkan-b580.md);Radeon 880M 核显见 [vulkan-880m.md](vulkan-880m.md)。Intel UHD 770 没有协作矩阵,`Auto` 仍走 CPU,显式 `Vulkan` 走较慢的无矩阵档,见 [vulkan-uhd770.md](vulkan-uhd770.md)。 +同机同轮:HOME-MAIN 5800X + RTX 3080 Ti(驱动 581.80),`.NET 10.0.11`,`--engine sharp|vulkan --workers 4 --benchmark-kind simd --warmup 1`,同一 `dataset/` 100 张变尺寸图(对 GPU 最不利的逐图新 shape)。墙钟 n=99。**CPU 列是同轮重测**,不要拿上表 1.4.2 的 `97c1448` mean 硬接(那次 tiny 63.1 / small 200 / medium 585)。B580 另一台机、另一份尺子,见 [vulkan-b580.md](vulkan-b580.md);Radeon 880M 核显见 [vulkan-880m.md](vulkan-880m.md)。Intel UHD 770 没有协作矩阵,`Auto` 仍走 CPU,显式 `Vulkan` 走较慢的无矩阵档,见 [vulkan-uhd770.md](vulkan-uhd770.md)。骁龙 8 Gen 3(Adreno 750,安卓)同样没有协作矩阵,但无矩阵档三档都快于本机 CPU,`Auto` 走 GPU,见 [vulkan-8gen3.md](vulkan-8gen3.md)。 端到端(median ms/图,越低越好;加速 = CPU median / Vulkan median): @@ -227,6 +227,7 @@ JSON:`bench-out/local-5800x-c-{tiny,small,medium}-4w.json`。 | 本机 5800X | — | `97c1448` / `68a009a` | 墙钟/内存 `97c1448`;CER 左 1:4 后同机复测(1.4.2) | | 本机 Vulkan | — | `8d62a35` | 5800X + 3080 Ti 同轮 sharp/vulkan 4w;tiny/small/medium;A/B 基线 `d6c260c` | | 880M Vulkan | — | `2520c77` | 锐龙 AI 9 H365 + Radeon 880M 同轮 sharp/vulkan 4w;A/B 基线 `713fc03` / `d6c260c` | +| 8 Gen 3 Vulkan | — | `vulkan-android-8gen3` 分支 | 真我 GT5 Pro,`test/Sdcb.SimdPaddleOCR.AndroidBench`,本机 CPU / Vulkan 交替 3 轮 4w;见 [vulkan-8gen3.md](vulkan-8gen3.md) | 推送或手动触发 [`.github/workflows/test.yml`](../.github/workflows/test.yml),下载 `perf-report` artifact。本地同一套数据: diff --git a/docs/vulkan-8gen3.md b/docs/vulkan-8gen3.md new file mode 100644 index 0000000..e4621b4 --- /dev/null +++ b/docs/vulkan-8gen3.md @@ -0,0 +1,145 @@ +# SimdPaddleOCR — 骁龙 8 Gen 3(Adreno 750)Vulkan + +实测机:真我 GT5 Pro(RMX3888),骁龙 8 Gen 3(SM8650),Adreno 750,16 GB LPDDR5X 与 GPU 共享,Android 16(API 36)。驱动报 Vulkan 1.3.128,`driverVersion`=0x802fa029(GLES 同版本串 V@0762.41)。进程是安卓应用 `test/Sdcb.SimdPaddleOCR.AndroidBench`(`net10.0-android`,CoreCLR,.NET 10.0.11),引用库的 `net10.0` 构建:CPU 走 AdvSimd 内核,Vulkan 直连系统 `libvulkan.so`。数据集用仓库 `dataset/` 100 张,`--workers 4 --warmup 1`,选项与桌面 `Tests` 相同,n=99。 + +**结论:Adreno 750 没有协作矩阵,compute subgroup 只能是 64 或 128,走无矩阵档(5.3 第 3 条)。原样上机时三档都算错(DET 概率图几乎全零,会话却报告 GPU 正常),修掉三处只在这颗 GPU 上暴露的问题后三档精度与本机 CPU 一致;再按 Adreno 调 GEMM 和两处路由,纯 GPU 快 14–33%。三档端到端都快于本机 CPU(tiny 约 1.3×,small 约 3.3×,medium 约 2.2×),所以 `Auto` 在“无协作矩阵且 subgroup 最小 64”的设备上改走 GPU。桌面 sg16 / sg32 / sg32l 的路由和 spv 没有改。** + +## 设备能力(`--caps` 探针) + +| 项 | 值 | +|---|---| +| 设备 | `Adreno (TM) 750`,vendor 0x5143,deviceId 0x43051401,`INTEGRATED_GPU`;加载的库是 `libvulkan.so` | +| subgroup | 默认 64;`VK_EXT_subgroup_size_control`,`sgRange` **64–128**,`requiredSubgroupSizeStages` 含 compute | +| 实际 lane 数 | 同一 `VkDevice.NewPipeline` 路径的探针 shader:请求 0 → `gl_SubgroupSize`=64,请求 64 → 64,请求 128 → 128;16 / 32 在范围外,按规范不能请求,没有请求 | +| cooperative matrix | 没有 `VK_KHR_cooperative_matrix`(`vkGetPhysicalDeviceCooperativeMatrixPropertiesKHR` 不导出),协作矩阵列表为空 | +| fp16 | `shaderFloat16`、16-bit storage 都有 | +| 共享内存 | `maxComputeSharedMemorySize` = **32 KB**;`maxComputeWorkGroupInvocations` 1024 | +| 绑定限制 | `maxStorageBufferRange` = **128 MB**;`minStorageBufferOffsetAlignment` = 64 B | +| 内存 | heap0 15203 MB、heap1 4095 MB,都 `DEVICE_LOCAL`;有 DL\|HV\|HC 和 DL\|HV\|HC\|CACHED 类型(输入缓冲落前者,回读落后者) | +| 队列 | family 0 `0x19f`×3(图形+计算),family 1 `0x2`×1(仅计算,被选中),family 2 `0x8`;三族 `timestampValidBits`=48,周期 52.08 ns,时间戳可用(探针 0.26 ms 一次 dispatch 读回正常);`globalPriority` 未授予(0) | +| push descriptor | 有 | +| 峰值 | `--peak`:纯寄存器 fp32 FMA **2.09 TFLOPS**;f16vec4 FMA 只有 0.91 TFLOPS(没有翻倍)。GEMM 必须 fp32 累加,按 2.09 算 | + +`CoopGemm` 为假、`SubgroupMin` > 16,所以 `GpuGraphModel` 进 `_nocm`,不创建任何 `conv1x1_cm*` / `convk_cm*` 管线。锁不住 8 lane,所以是新加的 `_ncWide` 子档。 + +## 走了哪一档 + +5.3 第 3 条:无协作矩阵。大 GEMM(1×1 里没被 dot 接住的、MatMul)走 `gemm_nc` 的 `-DDIRECT` 构建(`gemm_nc_d` / `gemm_nc_ds`),不请求 subgroup 大小(驱动给 64),内核不用 subgroup 内置量;kxk 稠密卷积一律走直接卷积 `convk_dot`;`conv1x1_dot` / `mmdot_sk` / `conv_dw*` / 其余内核照旧。能锁 8 lane 的 UHD 770 仍走原来的 `gemm_nc` / `gemm_nc_s`(`requiredSubgroupSize=8`),spv 逐字节不变。 + +## 上机后修掉的正确性问题 + +逐层对比用 `--layers`(把 GPU 图截断到节点 k,读回该节点输出的统计量),同一份代码链接进桌面 GpuBench,在 3080 Ti 上取参照;再用 `--gemm` 把单个 GEMM 和 fp64 参考比。 + +1. **`gemm_nc` 的寄存器预取在 Adreno 编译器下算错。** 第一处分叉就是第一个走 `gemm_nc_s` 的卷积;单测 6400×32×64 有 188871/204800 个输出错。fma 换乘加、if 换 select 都没用;去掉“下一块 A/B 先读进 `f16vec4` 数组”的预取、或把块缩到 32×32 / 4×4,就完全正确。改成 `-DDIRECT`:每块直接从全局读进共享内存。 +2. **标量常量上传只有 2 字节,Adreno 按绑定范围做越界检查。** `elem4` 按 `uint` 读 `b[0]`,桌面驱动不检查,Adreno 越界读回 0:CLS 头里乘常量那一步整个变 0,tiny CLS 只对 306/1022,转错方向的行又把 REC 拖到 CER 57%。`ConstF16` / `VecF16` 补齐到 16 字节的整数倍(只追加 0)。 +3. **im2col 矩阵超过 `maxStorageBufferRange`。** medium DET 960×960 的 7×7 卷积(M 57600,K 1568)im2col 有 180 MB,128 MB 之后全读成 0,medium 在大图上丢框(只检出 818 行,CER 13.2%)。放不进一个绑定时改走 `convk_dot`;仍会超范围的计划在运行前抛异常回退 CPU,不再返回半张零图。 + +试过但确认不需要的:把 arena slab 起点按 `minStorageBufferOffsetAlignment`(64 B)对齐、SE 计数器按 64 B 步进、把描述符 range 截到 128 MB。在这个驱动上都不改变任何输出(逐图、逐像素相同),所以没有留下;按规范它们仍属于未对齐 / 超范围的用法,换驱动时值得再看。 + +## 端到端(4 workers,median ms/图) + +同一时段 CPU / Vulkan 交替 3 轮(`ab.ps1`,轮间降温 60 s;tiny、small 第 2、1 轮的 CPU 因启动脚本竞态丢了,修好脚本后补测一轮,记在最后)。热状态(`PowerManager.CurrentThermalStatus`)tiny/small 为 2–3,medium 全程 3,机身明显发烫。 + +| 模型 | 本机 CPU | Vulkan | 对 CPU | +|---|---|---|---:| +| tiny | 218.8 / 202.6 / 276.4 | 156.1 / 170.4 / 154.9 / 151.7 | 约 1.3× | +| small | 867.1 / 785.0 / 811.7 | 251.3 / 247.3 / 246.2 / 255.1 | 约 3.3× | +| medium | 3073.8 / 3294.1 / 3368.9 | 1466.4 / 1458.1 / 1467.0 | 约 2.2× | + +- 发热是最大的误差来源:同一版本 medium Vulkan 在热状态 2 时 median 833 ms(det_graph 213、rec_graph 558),热状态 3 时 1460 ms(385 / 984)。纯 GPU 的最小值基本不受影响,受影响的是持续负载下的频率。表里 CPU / Vulkan 都是同一热状态下交替测的。 +- 工作集峰值:medium CPU 约 1.60 GB,Vulkan 约 1.09 GB;small 约 1.01 / 0.99 GB;tiny 约 0.96 / 0.95 GB。 + +分阶段 mean ms/图(第 3 轮): + +| 模型 | 阶段 | CPU | Vulkan | +|---|---|---:|---:| +| tiny | det_graph | 118.9 | 20.1 | +| tiny | rec_graph(CPU 为各线程累加) | 270.0 | 64.0 | +| tiny | lines_wall | 83.2 | 84.5 | +| small | det_graph | 384.4 | 40.2 | +| small | lines_wall | 382.5 | 166.2 | +| medium | det_graph | 1365.1 | 384.8 | +| medium | lines_wall | 2036.3 | 1010.8 | + +tiny 的收益全在检测上,识别墙钟与 CPU 4 线程持平。Vulkan 运行时 `crop` 阶段(CPU 做透视裁剪)从 9–12 ms 涨到 16–27 ms,推测是 GPU 忙时 CPU 频率被压低,没有再查。 + +### 精度 + +| 模型 | 本机 CPU exact_lines / CER | Vulkan exact_lines / CER | 逐图 hash/detected/texts 一致 | 框数一致 | +|---|---|---|---:|---:| +| tiny | 742 / 2.37% | 743 / 2.40% | 78/100 | 99/100 | +| small | 950 / 0.41% | 949 / 0.42% | 90/100 | 100/100 | +| medium | 1004 / 0.14% | 1003 / 0.15% | 97/100 | 100/100 | + +本机 CPU 三档与 Windows 基线(742 / 950 / 1004,CER 2.37% / 0.41% / 0.14%)完全一致;CLS 三档都是满分。DET 对拍(同一输入进 CPU / Vulkan 会话):tiny 10/10 图框数相同、概率图最大偏差 0.05–0.15(3080 Ti 对它自己的 CPU 是 0.03–0.18);medium 6/6 图框数相同、0.03–0.07。 + +并发(`--conc`,24 张 × 3):tiny 4 / 8 线程、small 4 线程、medium 4 线程、`Auto` tiny 4 线程,`mismatches=0`,日志里没有 fallback;medium 4 线程工作集峰值 980 MB,没有被系统杀掉。 + +## 纯 GPU + +`gprof.ps1`(逐 dispatch 时间戳,次数取最小,ms)。“调优前”是三处正确性修复之后、调优之前的版本;两列之间的每一步都做过同一时段交替 A/B,见各提交说明。 + +| 用例 | 调优前 | 调优后 | +|---|---:|---:| +| medium DET 960×960 | 380.5 | 298.6 | +| medium DET 640×960 | 270.1 | 205.2 | +| medium REC 8×480 | 358.9 | 242.3 | +| medium REC 1×320 | 40.0 | 34.4 | +| small DET 960×960 | 64.5 | 52.7 | +| small REC 8×480 | 72.6 | 54.5 | +| tiny DET 960×960 | 27.9 | 23.7 | +| tiny REC 8×480 | 15.9 | 12.3 | + +调优后热点:medium DET 是 `gemm_nc_ds` 92.6、`convk_dot` 88.9、`gemm_nc_d` 53.3、`conv1x1_dot` 19.4、`conv_dw4` 15.8、`conv_dw4t` 11.1;medium REC 是 `gemm_nc_ds` 110.9 + `gemm_nc_d` 95.7(合计 85%),其余是 `conv_dw4t` 20.6、`convk_dot` 6.3。 + +相对峰值:热点 GEMM 形状合计 0.64 TFLOPS,是 fp32 FMA 峰值 2.09 的 31%;`convk_dot` 在 57600×2304×64 上约 0.56 TFLOPS。 + +## 调优(每项都在手机上有收益才保留) + +1. **GEMM:fp16 共享内存 + BK 8。** `--gemmsweep` 在 11 个最热形状上交替比较(总 ms):原 64×64 / BK16 / fp32 暂存 109.5;BK 8 83.1;128×128、每线程 8×4 84.1;BK 8 + fp16 暂存 **76.2**(保留);同样配置换 128×64 / 64×128 块 74.6 / 75.0(噪声内,64×128 在 N=64 上浪费半块)。暂存的值本来就来自 fp16,逐位不变,e2e 与上一版 100/100。 +2. **两阶段空间均值在任意尺寸都用。** `reduce_hw` 一个工作组只算一个通道,在 Adreno 上按 C×2 字节跨步读;medium REC 8×480 269.7 → 243.3,medium DET 324.4 → 317.0。 +3. **kxk 稠密卷积一律直接卷积。** `CONVD_KMAX` 256 / 512 / 1024 / 2304:medium DET 315.2 / 312.3 / 304.0 / 299.3,small 64.6 / 60.7 / 52.8 / 52.8,tiny 32.8 / 29.9 / 23.7 / 23.7;640×960 216.9 → 206.2。直接卷积也不需要 im2col 暂存区。 + +重扫但保持原值的: + +- K≤128 的 dot → GEMM 门槛(M×cout > 2¹² / 2¹⁴ / 2¹⁶ / 2¹⁸ / 2²⁰):2¹²–2¹⁶ 相差 0.3 ms 以内,2¹⁸ 起 tiny DET 23.7 → 26.5,保持 2¹⁶。 +- `StreamCuts`(small 端到端,两轮):现有切分 median 234 / 235;不切分 254 / 290;0.3/0.6/0.85 236 / 258;0.7 252 / 262。CPU ArgMax 和后面的 GPU 波重叠在手机上仍然赚,保持原样。 + +## 试过但没留下的 + +| 尝试 | 结果 | +|---|---| +| 9×9 depthwise 走共享内存分块的 `conv_dw4t`(sg32 档允许到 9) | medium DET 303.7 → 317.0,small 52.7 → 55.3,更慢 | +| GEMM 不用共享内存,每线程直接按 f16vec4 读自己的 A/B 行 | 慢 2–4 倍;全展开的 8×8 版本又被编译器算错 | +| GEMM BK 4 / BK 32 / 32×32 4×4 块 | 83.2 / 200.6 / 148.1(对 76.2) | +| 共享内存按 f16vec4 向量读 | 76.8,与标量读相同 | +| fp16 乘法 fp32 累加 | 没做:f16vec4 FMA 峰值只有 fp32 的 0.43 倍,且会改变数值 | +| slab / 计数器按 64 B 对齐、描述符 range 截到 128 MB | 输出不变,没有收益 | + +## 留下的优化 + +- GEMM 只到峰值的 31%。不用共享内存、全靠缓存的版本慢 2–4 倍,推测 SSBO 读在这里命中不了足够近的缓存;下一步可以试通过纹理路径读(需要给管线加 texel buffer 绑定),没有验证。 +- medium DET 的 `convk_dot` 已经第二(88.9 ms),都是 7×7 / 大 K 卷积,M 方向 4 行一组,可以试更大的寄存器块。 +- REC 的 `conv_dw4t` 20 ms、DET 的 `conv_dw4` 16 ms。 +- 发热:持续跑 medium 时热状态到 3,端到端从 833 ms 涨到 1460 ms。更少的 dispatch / 更短的峰值比单纯缩短纯 GPU 时间更值。 +- 规范层面:未对齐的绑定偏移(64 B)和超出 128 MB 的描述符 range 在这个驱动上无害,但属于无效用法。 + +## `Auto` + +三档在所有交替轮次里 Vulkan 都快于本机 CPU,所以 `GpuBackend.UsesGpu` 对 `Auto` 在“没有协作矩阵、`SubgroupMin` ≥ 64”的设备上返回真(按能力判定,没有写死骁龙 / Adreno)。实测 `--backend auto`:usesGpu=True,与 `--backend vulkan` 逐图 100/100;`Auto` + `SIMD_OCR_BACKEND=cpu`:与 CPU 100/100。能锁 8 lane 的 UHD 770 和其它无矩阵设备(最小 subgroup < 64,例如不带协作矩阵驱动的 NVIDIA、Mali)没有在这条规则里,`Auto` 仍走 CPU,显式 `Vulkan` 才走 GPU。 + +## 桌面路径 + +sg16 / sg32 / sg32l 的 shader、spv 和选择条件都没动;全量重编后 `gemm_nc.spv`、`gemm_nc_s.spv` 和其余 spv 逐字节不变(`se_fused_dbg.spv` 删掉,不提交)。共享的 `GpuGraph` 改了两处会在桌面执行的地方:fp16 常量补齐到 16 字节、按 `maxStorageBufferRange` 的门槛和保护(桌面范围是 GB 级,不触发)。3080 Ti(驱动 581.80)同一时段对 `feature/2.0` 交替 2 轮,`--engine vulkan` median ms:tiny 18.0 / 19.9 → 18.5 / 20.7,small 26.7 / 30.3 → 29.2 / 28.5,medium 36.5 / 37.6 → 37.3 / 37.3(噪声内);精度 741 / 950 / 1006 不变;逐图 100/100(三档),`--engine sharp` tiny / small 100/100。B580、880M、UHD 770 没法测:它们都不满足 `SubgroupMin` ≥ 64,走的路由没变,只有常量缓冲补齐这一处共享改动。 + +## 复现 + +```text +cd test/Sdcb.SimdPaddleOCR.AndroidBench +.\run.ps1 -Build -Install -Push -Bench "--caps" # 推 bench-out/models 和 dataset/,打印能力 +.\run.ps1 -Bench "--e2e --model medium --backend vulkan" # JSON 与 logcat 拉回 bench-out/android +.\ab.ps1 -tag x -models tiny,small,medium -rounds 3 # CPU / Vulkan 交替 +.\gprof.ps1 -tag x # 纯 GPU +``` + +需要 .NET SDK ≥ 10.0.302 和 `android` workload、Android SDK platform 36、JDK 17/21。模型从手机存储读(medium 的 DET+REC 约 130 MB,不打进 APK)。