ModelOptions.Device = ComputeDevice.Gpu offers a session's operators to a graphics processor, through the RecursiveAI.OneModel.Gpu
package (Vulkan, Metal or browser WebGPU, from managed code, on RecursiveAI.OneGpu 1.0.0 and its backend packages). The core package knows nothing about it: the GPU package registers
itself when asked.
OneModelGpu.Register(); // once per process using var session = Session.Load("model.onnx", new ModelOptions { Device = ComputeDevice.Gpu }); Console.WriteLine(session.GetDiagnostics(inputs).NodesOnDevice);
Which operators run where
A kernel table decides. Each GPU operator is one entry (operator type, whether a deterministic kernel exists, a check of attributes and opset, and a binder that sees element types and shapes). Float32, float16, int32, int64 and boolean tensors, uint8 activations of the quantized product, uint8 and int8 values that a
QuantizeLinearmakes and aDequantizeLinearreads, and uint8 tables and values that a lookup and aDequantizeLinearhandle, run on the device; other element types (16-bit integers, uint8 elsewhere, float64, strings) stay on the CPU. A boolean tensor is one byte per element, as on the host.Family Operators Matrix product MatMul(numpy batch broadcasting when B has no batch, both batches are equal, or A has none),Gemm(transA,transB,alpha,beta, aCthat broadcasts)Attention the fused attention node (below), one kernel for softmax(scale * Q K^T + mask) VElementwise, with ONNX broadcasting AddSubMulDivPow(float only)MinMax(two inputs;AddSubMulDivMinMaxNegAbsalso on int32 and int64),SqrtReciprocalExpLogTanhErfSigmoidReluLeakyReluClipGeluNormalisation and reductions LayerNormalization(opset 17; theMeanandInvStdDevoutputs must be unused),Softmax(opset 13),ReduceSumReduceMeanReduceMaxReduceMinReduceProdReduceL1ReduceL2ReduceSumSquareReduceLogSum(axes constant)Integers, booleans, masks Mod(int32, int64; bothfmodforms)Equal(float, int, bool)LessGreaterLessOrEqualGreaterOrEqual(float, int)AndOrXorNotWhere(float, int values)Expand(a constant shape)RangeandConstantOfShape(constant inputs), all bit-exact against the CPULookup Gather(float32, float16, uint8, int32, int64 or boolean data, int32 or int64 indices; float16 rows need an even length)Layout Transpose,Castbetween float32, float16, int32, int64 and bool, and the viewsReshapeFlattenSqueezeUnsqueezeIdentityDropout(of any of those types)Vision Conv(one or two spatial axes; groups, depthwise, dilation,auto_pad, bias; the fused forms below; float16 models' convolutions run in float16, with float32 accumulation),ConvTranspose(explicit pads andoutput_padding, groups),MaxPool(without the indices output),AveragePool(ceil_mode,count_include_pad),GlobalAveragePool,GlobalMaxPool,Resize(nearest and linear; every coordinate mode buttf_crop_and_resize; constant scales or sizes on the two spatial axes),Concat(one dispatch per four inputs, however large the batch),HardSwish,HardSigmoid; float32 or float16 tensors in NCHW, weights OIHW. A float16 model's convolutions are not fused with the activation and residual that follow (the graph rewrites leave the float16 form as written), so each is its own pass8-bit DynamicQuantizeLinear(float32 input), and the dequantizing productMatMulInteger, Cast, Mul [, Add]that the graph rewrite makes (uint8 A, constant uint8 or int8 B, optional zero points, a scalar or per-column scale, an optional constant bias), andQLinearMatMul(uint8 A, constant uint8 or int8 B, constant scales and output zero point, zero points per tensor or per row/column, onemodel's optional int32 bias; uint8 result), andDequantizeLinear(uint8, int8 or int32 input; per tensor or per axis, optional zero point of the input's type, float32 scale and result, noblock_size), andQuantizeLinear(float32 input and scale, uint8 or int8 result with a zero point of that type, per tensor or per axis, noblock_size; 16-bit results stay on the CPU), andConvInteger(one or two spatial axes; uint8 or int8 input and weights in any mix; a scalar input zero point and a scalar or per-channel weight zero point; groups, dilation, strides, padding,auto_pad; int32 result), bit-exact against the CPU.ConvIntegerunfolds the windows into a column matrix kept in the arena for the length of the step (at most 64 MiB, the batch runs in chunks of images beyond that) and multiplies with the 8-bit productRecurrent LSTM,GRU,RNN(float32; forward, reverse, bidirectional; layouts 0 and 1; bias, initial states, peepholes,clip,input_forget,linear_before_reset,sequence_lens, every activation ONNX names with itsactivation_alphaandactivation_beta); a bidirectional LSTM recognizer is one region with its convolutionsVision models. ResNet-50, MobileNetV3 and the detector of the reference set run as one region with all of their operators on the device (
RequireGpupasses). The graph rewrites the convolution nodes take are the device's too: batch-norm folding (in both modes), the activation (Relu,Clip,HardSwish,HardSigmoid,Sigmoid,Gelu,Tanh,LeakyRelu) and the residualAddfused into the convolution's last pass (also in exact mode, where the CPU would not fuse the residual: the device's own kernel takes it in either mode). A residual that broadcasts, a convolution of three spatial axes,Resizewith cubic interpolation,antialias,exclude_outsideor run-time scales, andMaxPoolwith its indices output stay on the CPU, each with its reason in the diagnostics. Float16 convolutionsQLinearConvis not mapped;ConvIntegeris (below). The quantize-dequantize form of the 8-bit vision models (ResNet-50 and MobileNetV3 with aQuantizeLinear/DequantizeLinearpair around each float operator) runs as one region: each pair is computed on the device with the CPU's arithmetic, and the convolutions between them are the float32 ones, so the result is the quantized model's, not the integer kernels' (their rounding differs in the last bit, which a quantization step can turn into a one-step difference that spreads). Against the CPU's exact mode the model-level error is of the size of the CPU's own fast-versus-exact difference on the same input (ResNet-50 1.7e-2, MobileNetV3 up to 1.3e-1). A concatenation is one copy per input and image (the device's concatenation refuses an input in the output's buffer, and an arena slab holds both), so a batch of more than a few hundred copies stays on the CPU.Everything else stays on the CPU, per operator, unless
RequireGpuis set. The reason for each operator that stayed is inGetDiagnostics: no kernel, a type or attribute the kernel does not take, a size above a device limit, a shape that is only known at run time, or exact mode with no deterministic kernel. The attention-mask chain of MiniLM and BERT (Cast,Gatherof the mask,And,Expand,Where, and at larger sizes theRange,ConstantOfShapeand comparison that build the causal triangle) runs on the device, so a float32 or float16 encoder is one region and passesRequireGpu. A dynamically quantized (int8) encoder is one region too: its embedding tables'Gather(uint8 data) and theDequantizeLinearafter it run on the device, and the rows are dequantized with the CPU's own arithmetic ((float)(x - zero_point) * scale, one rounding), so the values match the CPU's bit for bit.Recurrent layers.
LSTM,GRUandRNNuse the layer's attributes as the CPU operator resolved them, and the weights as stored (no repacking). The input projection of every step is one batched matrix product, then one dispatch per step (a dispatch cannot wait for every other workgroup of itself, so the steps are separate passes); each recurrent dot product is summed in an order fixed by the shapes, so fast and exact mode run the same kernel and a run is bit-identical to the next.sequence_lensis checked on the host with the CPU's message and range (at planning for a constant, on every run for a value from the host); lengths made on the device stay on the CPU. A layer's two scratch buffers (projection and states) are shared by all layers of a session, sized at planning, counted inArenaBytes. Float16 layers and an empty sequence or batch stay on the CPU.Two operators that are built from others.
Expandis a selection (Wherefor numbers,Andfor booleans) over a scratch of "true" values of the target shape, because the device's own expand needs separate input and output buffers and an arena slab is one buffer. AGatherof booleans widens the data to int32 scratch, gathers, and narrows it back. Both are exact for 0 and 1.Views cost nothing.
Reshapeand its kin give their result the memory of their input: the arena hands the result the same slot, and the step records no commands. A view whose result a CPU operator reads (or the graph returns) stays on the CPU, because the device holds the only copy of its source; the source is then read back instead.Regions. Operators on the GPU form a region. The plan orders the schedule so that operators the GPU did not take run as early as their inputs allow, which puts the GPU's operators next to each other (unrelated CPU work no longer splits a region). A region is one upload of the tensors it reads from the CPU, one command submission, and one readback of the tensors the CPU reads afterwards (or the graph returns). Nothing is copied inside a region, so a model whose operators all run on the GPU moves its inputs and outputs only. Weights are uploaded once per session; float16 weights with an odd element count are padded by one element, because the kernels read half-precision data in pairs.
Graph rewrites. The device chooses which of the session's rewrites run. It takes the convolution rewrites (above), the fused attention (
softmaxover the scaled, masked scores between two products, with the head-split and head-merge transposes absorbed: the kernel reads and writes the strided layouts), the one-passGelumade from the five-operator form, and the dequantizing 8-bit product, in both modes (the device kernels are deterministic). It declines the CPU's packed-weight rewrites (LinearOp, convolution packing), whose weights are laid out for the CPU kernels. A model with float16 values of its own keeps its graph as written, so it runs the product, softmax, norm and elementwise operators separately.GpuOptions.FuseOperators = falseturns the two device fusions off.Bias and activation in the product.
MatMulfollowed by anAddof a constant bias vector (one value per output column) and thenReluorGelubecomes one dispatch when nothing else reads the intermediate results: the product's kernel adds the float32 bias and applies the function to each element as it writes it. The float32 result has the bits it has with the operators run separately (the epilogue is the elementwise kernels' own add and function; the tests compare hashes), in fast and exact mode, so exact mode stays run-to-run identical. A float16 product folds in fast mode only, where skipping the intermediate rounding to half precision stays within the float16 tolerance; exact mode keeps the operators separate. A product that starts from aGemmCis not folded. A BERT or MiniLM layer saves one dispatch per projection and two per feed-forward block.GpuOptions.FuseEpilogues = falseturns it off.Attention details. The mask is the additive float mask of the node, broadcast over batch, heads and rows; a mask with one value per row (last dimension 1) is spread across the keys once into scratch. A query row with no visible key gives NaN, as the CPU's softmax does. The head dimension is at most 256. A product of more work than the device's submission budget (Metal) goes in one submission per batch entry.
Gather and indices. An index outside its axis fails the run with
SessionErrorKind.InvalidInput, "Gather index 40 is out of range for an extent of 40.", the CPU's error and message. Indices from the host are checked in safe code before the upload; indices the device made are recorded by the kernel in a status word read after the submission (the message then gives the position instead of the value). A constant index that is out of range keeps the operator on the CPU, which raises the error.Weights larger than one buffer binding. A vocabulary table or logits projection (51,865 by 768 float32 is 159 MB) is above the 128 MiB some devices bind in one storage buffer. Such a constant is uploaded as several buffers cut along whole rows (a lookup, or a product with
transB) or whole columns (a product, repacked dense), each within the limit. AMatMulorGemmwith such a weight runs one product per piece, each writing its columns of the result, so every output is summed in the order the single-buffer kernel uses and the bits equal the unsplit run's. AGathercopies each requested row from the piece that holds it, so it needs indices from the host or constants (an out-of-range index is the CPU's error, as above) and is limited to 65,536 rows per run; a product with a bias, indices made on the device and a result larger than one binding stay on the CPU with that reason. Float32 products and float32 or float16 lookups only.Long regions are cut into submissions. Where the platform names no budget for one submission, a region's recording goes to the device after about 34 GFLOP of kernel work and continues in a new command buffer, so a slow or shared device (the integrated GPU of a desktop processor takes seconds for a speech encoder) never holds the queue for long enough for the driver to reset it. The cut falls between operators, keeps their order, and changes no bit of the result; on a discrete card 34 GFLOP is a few milliseconds.
Speech models (encoder and decoder)
The reference speech model is two graphs, as exported: an encoder (log-mel input [N, 80, 3000], two one-axis convolutions with Gelu,
a position add, twelve transformer blocks) and a decoder that takes the whole token sequence and the encoder states at every step and
returns the logits of every position (there is no key/value cache in this export, so each step recomputes the attention keys and values of
the whole prefix and the cross-attention projections of the 1500 encoder positions). Both graphs run completely on the device, one region
each, and pass RequireGpu. The sequence length of the decoder changes at each step, so each step builds a plan for its length (the
second run of a length reuses it, 16 plans are kept) and uploads the few constants the plan folds (about 13 buffers); the weights and the
arena stay. Per step the device receives the token ids and the encoder states and returns the logits.
Against the CPU's exact mode (scaled error max|gpu - cpu| / max|cpu| of every output, a synthetic log-mel of normal values with deviation
0.5): the decoder's logits are within 3.2e-6 at every step of a 64-step greedy run on every device, the greedy tokens equal the CPU's, and
the encoder's states are within 7.1e-5 to 9.2e-5 at batch 1 (fast and exact, RTX 4070, an AMD integrated GPU, an Apple M2, a software
rasteriser). At batch 4 the encoder's error is 1.6e-4 to 3.2e-4 (fast and exact, RTX 4070, an Apple M2, a software rasteriser; 1.1e-4 on both with the chain instead of the fused
attention). That is the model's float32 noise, not a fault of one kernel. tools/GpuReport with --attribute compares every executor with a float64
evaluation of the same graph (Whisper64.cs, tools only): per item the CPU's exact mode is 2.9e-5 to 1.4e-4 away from the true value (median 5.5e-5, 16 inputs) and the GPU
is 4e-5 to 2.8e-4 (mean 8.7e-5 fast, 9.4e-5 exact, against 6.4e-5 for the CPU). An item's error does not depend on the batch it sits in (item 0 has the same error at
batch 1 and batch 4), and fusing attention, Gelu and the bias epilogue moves the mean by less than the spread between items (7.9e-5 unfused, 8.7e-5 fused). So two
independent float32 evaluations differ by about 1e-4 per item, and the largest of four items sits above or below 1e-4 by the draw.
The size of the error comes from the sums. The CPU and the device add the terms of every product and convolution in one chain (increasing k, fused multiply-add) and produce the same stage errors to the last digits (a stage from the same rounded input: 0.7e-6 to 1.3e-6 scaled, CPU and device alike). The graph amplifies early rounding by a factor of 20 to 30: the two stem convolutions alone, with one chain each and every other operator exact, give 1.6e-5 to 7.4e-5 at the output (mean 3.8e-5). With every sum evaluated in float32 and the rest in float64, per item over eight inputs: one chain 7e-5 on average (2.6e-5 to 1.8e-4), eight interleaved chains added pairwise 1.0e-5 (5.5e-6 to 1.6e-5), compensated sums 4.4e-6. The 1e-4 gate against the CPU's exact mode cannot hold for every input of this model while the CPU keeps one chain; the device would be closer to the true value than the CPU with eight lanes in its matrix product and convolution kernels. The greedy tokens of a 16-step decode with the device's encoder and decoder equal the CPU's at batch 1 and 4 on the RTX 4070 and the M2, and at batch 1 on the software rasteriser.
With float32 sums taken in blocks (32 terms, then groups of 128, joined in increasing k; the matrix kernels' f16 and bf16 paths keep one chain), the same 16 inputs at batch 4 give a mean error against float64 of 1.02e-4 (fast) and 1.08e-4 (exact) on the RTX 4070 and 1.15e-4 and 1.09e-4 on the M2, against 8.9e-5 for the CPU's exact mode: 1.14 and 1.21 times the CPU's on the RTX 4070 (before the blocks 1.45 and 1.57), 1.28 and 1.22 on the M2 (1.47 and 1.60). At batch 1 (16 inputs) the means are 7.1e-5 fast and 6.9e-5 exact on the RTX 4070 (CPU exact 7.0e-5), 8.7e-5 and 8.0e-5 on the M2. Per item the device is below the CPU's exact mode on 6 to 8 of 16 inputs at batch 4 on the RTX 4070 and on 4 of 16 on the M2, and on 4 to 7 of 16 at batch 1. The 1e-4 gate against the CPU's exact mode still fails on that model for the inputs where the CPU itself is far from the float64 value (up to 3.9e-4 at batch 1 on the RTX 4070).
Exact mode is bit-identical from run to run on each device.
The dynamically quantized speech encoder runs as one region too, its two stem convolutions (ConvInteger, int8 weights, per-channel zero points) on the device. The first stem's output
is bit-identical to the CPU's, as every integer sum is (the tests compare ConvInteger bit for bit on one and two spatial axes, groups, dilation, strides, padding, per-channel zero
points and every mix of uint8 and int8). Further in, the float operators between the quantization steps differ in the last bits (the CPU's exact mode uses its own polynomials for
Erf and Exp), and a quantization step turns such a difference into a one-step difference that spreads: after the second stem the largest difference is 2e-3 on values up to 6.1
(rms 1.4e-5), and at the output the scaled error against the CPU's exact mode is 0.34 to 1.0 on the devices tried, against 0.45 between the CPU's own fast and exact modes on the same input.
Two runs on one device are bit-identical in both modes.
Fused or chained attention
The fused attention kernel is the default everywhere, browsers included. GpuOptions.FusedAttention = false runs the chain it replaces instead (two
products, the mask addition and a softmax; both forms are deterministic in exact mode). Apple M2, fast mode, fused against chain: the speech
encoder (1500 keys) 355 ms against 428 ms at batch 1 and 1.32 s against 1.62 s at batch 4; on the RTX 4070 the speech encoder at batch 4 is 210 ms against 261 ms.
The error against the CPU's exact mode is about 1e-5 for either form on the transformer encoders; the speech encoder at batch 4 shows 1.8e-4 (fused) and
1.1e-4 (chain) against a floor of 5e-5 to 7e-5 that the CPU's own exact mode shows when its input moves by one unit in the last place.
Speed on Apple silicon
Indicative, Apple M2 (4 performance and 4 efficiency cores), fast mode, median of 20 runs, wall time of the device run against the CPU's fast mode (4 threads, median of 10), in milliseconds. Batch 1: MiniLM 5.5 against 24.1, BERT-base 23.4 against 69.8, ResNet-50 17.7 against 38.4 (best CPU run 25.6), MobileNetV3 5.3 against 13.8, DBNet 5.2 against 7.8, the recognizer 2.1 against 29.4 (best CPU run 3.8), the speech encoder 352 against 1395 (8-bit: 705 against 898), BERT-base 8-bit 60 against 70. Batch 8: MiniLM 23.3 against 78.8, BERT-base 140 against 552, ResNet-50 97 against 198, MobileNetV3 20.2 against 22.9.
Consecutive passes of a run share one Metal compute encoder, so a small model's cost is its kernels plus a couple of microseconds per dependent pass. Metal samples its
counters only at encoder boundaries, so timing a pass splits the encoder around it and costs about 20 microseconds. With CollectTimings on, the device time of a run is
therefore taken as one span, from the start of the first operators' passes to the end of the last ones, and the passes between them run untimed: the timing no longer
slows the run it measures (same build, timing as one span against timing every pass, batch 1: MiniLM 6.4 against 7.5 ms, MobileNetV3 5.4 against 7.3, the recognizer 2.1
against 3.6, ResNet-50 17.6 against 20.0). GpuSessionInfo.Timings.Dispatches and ComputePasses are counted by the command encoder as it records, so they are the same on every backend with or without
timing; GpuReport --profile times each step on its own and prints GPU time and dispatches by operator (BERT-base 8-bit: 328 passes, the speech encoder 8-bit: 357).
Host cost of a run
With CollectTimings a run's host time is split into recording (binding operands and encoding the passes), the submit calls, and the rest (waiting for
the device and copying the outputs back): GpuTimings.RecordMilliseconds, SubmitMilliseconds, and SubmitAndReadbackMilliseconds minus both.
Recording is small (RTX 4070 Vulkan, batch 1: BERT-base 0.4 ms for 146 dispatches, MiniLM 0.2 ms for 80, the speech decoder 0.6 ms for 256) and the
bind groups are all cached; the cost that showed up in wall time was that the device sat idle until the whole region was recorded and handed over.
A region of more than a few operators is therefore handed to the device in pieces while it is still being recorded: the first piece after 6
dispatches, each later piece twice as large. Barriers between pieces are tracked per buffer, so bits are unchanged (a test compares them, and the
counts). Wall time against the summed kernel time, same device: BERT-base 9.80 -> 9.31 ms (kernels 8.85 / 8.61), MiniLM 2.83 -> 2.65 (2.20 / 2.21),
speech decoder 19.94 -> 19.55 (18.15 / 18.11); in Chrome on the same GPU 26.0 -> 24.1, 12.0 -> 10.0, 42.9 -> 41.8 ms. On Metal it loses: every commit
costs enough that the 24 ms BERT run becomes 27 ms, so Metal hands a region over whole; there the run is already within 0.5 to 1.3 ms of its kernel time
(BERT 24.65 against 23.90, MiniLM 5.64 against 5.16, speech decoder 87.25 against 85.92). What remains on Vulkan, 0.5 to 1.5 ms, is about 5 microseconds
per dispatch, the size of the gap the device leaves between dependent passes; recording is hidden behind the kernels now.
The contracts later extensions rely on
- Op table (
GpuOpTable). OneGpuOpEntryper ONNX operator type:Checkruns at session creation with attributes only,Bindruns per plan with element types, shapes and constants known, and returns aGpuBindingor the reason in words. Adding a kernel family is adding entries; nothing else changes. A binding holds everything its kernel call needs (shapes, constants on the device, the kernel mode) and does no allocation when it encodes. - Partition. The core offers every statically shaped step to
ClaimStepin schedule order, then forms regions from the accepted steps (see above).Compilegets the regions once, after the schedule is final. - Arena.
GpuProgram.Createturns each region into lifetimes: a value lives from the step that makes it (or the start of the region, for a region input or an output a kernel starts from) to its last reader (or the end, for a region output). A view's lifetime extends its source's. A binding may also ask for float32 scratch for the length of its step (the float16 product accumulates in float32 and rounds once; so do the convolutions, the recurrent layers and the requantizing product). Offsets are chosen by first fit per element type (float32, float16, int32, int64, uint8); slabs only grow. - Folding. After the bindings of a region exist,
GpuProgramlooks for a product whose sole reader is a biasAddand then an activation, hands them to the product's binding and replaces the absorbed steps with ones that record nothing and read and write no value. The product then writes the last result's value, and lifetimes are planned from the changed inputs and outputs.
RequireGpu
Creating the session fails with SessionErrorKind.DeviceUnsupported, SessionException.Operators and the message naming every
operator that cannot run on the device and why. An operator whose inputs are all known before the run (weights, shape arithmetic) might be
evaluated once while the plan is made and never run; it is not named at creation, and the plan checks it again, with the folding done, for
the shapes it is built for. So RequireGpu can also fail the first run or GetDiagnostics of a shape, naming what is left. A reason that
depends on the shapes of the first run (an operand larger than the device's binding limit) fails that run the same way. RequireGpu needs
Device = Gpu. If no device exists at all, creation fails with DeviceUnsupported whatever RequireGpu says.
Fast and exact mode
- Fast uses the fastest kernels, including matrix hardware where the device has it. The result is within the fast-mode tolerance
of the CPU (scaled error
max|gpu - cpu| / max|cpu|, about 1e-6 to 1e-5 for the float32 encoders; float16 models round to 2e-3). - Exact uses kernels with a fixed summation order, no atomics and no scheduling dependence: two runs on the same device and
driver give the same bits. Products, softmax, normalisation and reductions have deterministic variants. Elementwise operators and
layout operators have no reduction order, so one kernel serves both modes and is bit-reproducible on a device. That includes the
transcendental functions (
Erf,Tanh,Exp, ...): they are reproducible on one GPU and driver, but they are not the CPU exact mode's fixed polynomials, so a result matches the CPU's exact mode only within the float32 tolerance (1e-5 per operator, 1e-4 for a whole model), and another GPU, driver or adapter may differ in the last bits. An operator with no deterministic kernel stays on the CPU. - Float16 values stay float16 on the device. Products accumulate in float32 and round once; the elementwise, normalisation and softmax kernels compute in float32 and round once per operator, like the CPU's float16 path. A float16 softmax or layer normalisation needs an even row length (the kernels write rows as 32-bit words), otherwise it stays on the CPU.
Device memory
The scratch a kernel needs while it runs lives in the arena for the length of its step, so it costs memory only then and is reused by the steps after it: the column matrix and accumulators of the convolutions that unfold their input (none on Apple silicon in float32, where the convolution gathers windows while it multiplies), the transposed convolution's columns, the recurrent layers' projection and state, the sums of a requantizing 8-bit product. A convolution gets the whole batch's worth up to 64 MiB, else as many whole images as fit (at least one); a smaller buffer runs the batch in chunks with the same bits.
One arena per session holds the intermediate tensors of every plan. Offsets are planned from tensor lifetimes (a tensor's memory is
reused once the last operator that reads it has run), per element type, in slabs no bigger than one buffer binding
on the device. The arena only grows, and only when a plan needs more than any before it, so the second run of a shape, and a run of any
smaller one, allocates nothing on the device. OneModelGpu.GetInfo reports the arena's size, the weights' size and the number of device
allocations made so far.
Every kernel family (matrix products, elementwise, norm, softmax, reduce, transpose, integer, gather, attention, the 8-bit product, dynamic
quantization and dequantization) keeps its bind groups (the device's descriptor sets) by the buffers they name, up to 4096 sets, which is more than the
weights of the largest encoders touch, so a warm run builds none. When the arena grows and replaces its buffers the families drop their
cached groups with ReleaseBindings, so the old buffers are released; the families themselves, with their pipelines, stay, and the next run
builds each set once more. The dequantizing 8-bit product applies its scale and biases as it stores each sum, so it is one dispatch and keeps nothing between passes; the requantizing one takes its int32 sums from the arena.
In a browser (WebGPU)
The package runs on the browser-wasm runtime through the WebGPU backend of RecursiveAI.OneGpu. A browser has one thread and cannot wait
for the GPU, so two calls differ from the desktop ones:
OneModelGpu.Register(); await OneModelGpu.PrepareDeviceAsync(); // before each GPU session: the browser makes its device asynchronously using var session = Session.Load(modelBytes, new ModelOptions { Device = ComputeDevice.Gpu }); using SessionOutputs outputs = await OneModelGpu.RunAsync(session, inputs); // same results as Run, without blocking
OneModelGpu.RunAsync works on every platform and gives the bits of Session.Run. In a browser a blocking Run that reaches a device region
throws a SessionException (DeviceFailure) saying the browser cannot wait for the GPU; a model that runs entirely on the CPU can still use Run.
Awaited runs of one session take turns. Creating a GPU session without PrepareDeviceAsync first throws DeviceUnsupported. A session owns the
device made for it, so prepare one per session; sessions can live side by side in one page, each on its own device (a model's encoder and decoder, or a
fallback session made after RequireGpu refused the first).
What the browser changes, all invisible in the results:
- Buffers. A browser refuses one buffer that a dispatch binds both for reading and for writing, even at disjoint ranges. The arena
therefore keeps an operator's inputs, outputs and scratch in different slabs (more slabs and more memory: 9.0 MiB against 5.0 MiB for MiniLM at batch 1), and the boolean
gather uses a second scratch for its gathered words. The same layout can be tried on any device with
ONEMODEL_GPU_SEPARATE_SLABS=1; it changes no bits. - Attention.
GpuOptions.FusedAttentionnull means the fused kernel in a browser too. In Chrome on an RTX 4070 (warm run, fused against the chain): the speech encoder 68 against 81 ms, BERT-base at batch 1 19 against 21 ms and at batch 8 39 against 41 ms, MiniLM at batch 1 8.2 against 8.5 ms; on SwiftShader (a software adapter) MiniLM 576 against 888 ms and BERT-base 3.8 against 4.1 s. Set it to false to run the model's own operators; results are within the same tolerance. - Float16 models run without the
shader-f16feature: the kernels read and write 16-bit values packed in 32-bit words. - Subgroups and matrix hardware are not used; the portable variants run.
- Memory. Model bytes and the prepared weights both live in the runtime's linear memory (4 GiB at most). The float32 speech decoder (774 MB file) loads and runs; a model of 1.1 GB or more has not been tried. Weights above one buffer binding are split as on the desktop.
- Operators that stay on the CPU run in WebAssembly, which takes much longer than the desktop CPU path. The reference set has none left: the speech encoder's 8-bit
convolutions (
ConvInteger) run on the device since they were mapped (1.1 s a run in Chrome before, 45 ms after).
tools/gpu_browser.sh runs reference-set models in headless Chrome and prints parity against the desktop CPU exact mode, placement,
RequireGpu outcome, repeatability and timing next to the desktop GPU run and onnxruntime-web's WebGPU provider.
Errors
Device failures are SessionExceptions that name what was being done: DeviceFailure for out of memory (with the size asked for),
a lost device and a missing capability; Unsupported for a shape a kernel rejects. After a lost device, create a new session.
Concurrency
Concurrent runs of one session overlap on the CPU but take turns on the GPU (one device per session, guarded by a lock).