diff --git a/docs/ContribOperators.md b/docs/ContribOperators.md
index d45736fe9e6f1..b96f499ead85b 100644
--- a/docs/ContribOperators.md
+++ b/docs/ContribOperators.md
@@ -73,6 +73,7 @@ Do not modify directly.*
* com.microsoft.MatMulIntegerToFloat
* com.microsoft.MatMulNBits
* com.microsoft.MatMulNBitsMlp
+ * com.microsoft.MatMulNBitsQLoRA
* com.microsoft.MatMulNBitsQkv
* com.microsoft.MaxpoolWithMask
* com.microsoft.MoE
@@ -4503,6 +4504,69 @@ This version of the operator has been available since version 1 of the 'com.micr
+### **com.microsoft.MatMulNBitsQLoRA**
+
+ Computes MatMulNBits(X,W,scales) + MatMulNBits(MatMulNBits(X,lora_A,lora_A_scales),lora_B,lora_B_scales). Each projection follows MatMulNBits block quantization and bit packing, with implicit zero points 2^(bits-1) for the base and 2^(lora_bits-1) for the adapters. No bias or explicit zero points are applied. The adapter rank R may be any positive integer and need not be a multiple of lora_block_size. Partial final blocks are padded; only the logical K or R elements participate in each projection. An empty rank dimension in either packed adapter disables the adapter, while scale defaults are retained. Intermediate projections and block scales use the activation type before the final addition. Adapters remain replaceable. Execution providers may support a subset of the defined types, quantization settings, and ranks.
+
+#### Version
+
+This version of the operator has been available since version 1 of the 'com.microsoft' operator set.
+
+#### Attributes
+
+
+- K : int (required)
+- Positive input feature dimension of the base and down-projection weight matrices.
+- N : int (required)
+- Positive output feature dimension of the base and up-projection weight matrices.
+- accuracy_level : int
+- Accuracy policy following MatMulNBits: 0 (unset), 1 (fp32), 2 (fp16), 3 (bf16), or 4 (int8). Applies to the base and eligible adapter down projections; the up projection uses level 0.
+- bits : int
+- Base weight bit width, following MatMulNBits (2, 4, or 8).
+- block_size : int
+- Base quantization block size along K, following MatMulNBits: a power of two, at least 16.
+- lora_bits : int
+- Adapter weight bit width, following MatMulNBits (2, 4, or 8).
+- lora_block_size : int
+- Adapter quantization block size along K for lora_A and R for lora_B: a power of two, at least 16.
+
+
+#### Inputs
+
+
+- X : T
+- Input of rank at least one, with last dimension K, following MatMulNBits.
+- W : TQ
+- Packed base weights [N,ceil(K/block_size),block_size*bits/8].
+- scales : T
+- Base block scales [N,ceil(K/block_size)]; may be flattened.
+- lora_A : TQ
+- Packed down weights [R_A,ceil(K/lora_block_size),lora_block_size*lora_bits/8], R_A=R or 0.
+- lora_A_scales : T
+- Down block scales [R,ceil(K/lora_block_size)]; retained when the adapter is empty.
+- lora_B : TQ
+- Packed up weights [N,R_blocks,lora_block_size*lora_bits/8], R_blocks=ceil(R/lora_block_size) or 0.
+- lora_B_scales : T
+- Up block scales [N,ceil(R/lora_block_size)]; retained when the adapter is empty.
+
+
+#### Outputs
+
+
+- Y : T
+- Input shape with the last dimension replaced by N.
+
+
+#### Type Constraints
+
+
+- T : tensor(float16), tensor(float)
+- Activations and block scales share the same floating-point type (FP16 or FP32).
+- TQ : tensor(uint8)
+- Packed quantized weights.
+
+
+
### **com.microsoft.MatMulNBitsQkv**
MatMulNBitsQkv fuses either SimplifiedLayerNormalization (RMSNorm)
diff --git a/docs/WebGPU_MatMulNBitsQLoRA.md b/docs/WebGPU_MatMulNBitsQLoRA.md
new file mode 100644
index 0000000000000..895c588986e61
--- /dev/null
+++ b/docs/WebGPU_MatMulNBitsQLoRA.md
@@ -0,0 +1,236 @@
+# WebGPU MatMulNBitsQLoRA
+
+`com.microsoft.MatMulNBitsQLoRA` combines a quantized base projection with a
+quantized LoRA adapter during inference. Adapter weights remain separate and
+replaceable; the operator does not merge them into the base weights or train them.
+It reduces dispatches and intermediate memory traffic by sharing input preparation
+and computing the adapter contribution inside existing MatMulNBits shaders.
+
+## Operator contract and current support
+
+The [operator definition](ContribOperators.md#com.microsoft.matmulnbitsqlora)
+follows MatMulNBits quantization and packing rules. It allows arbitrary positive
+adapter ranks, including ranks not divisible by eight or the block size. Partial
+blocks are padded, and only logical elements participate in the projections.
+
+The current WebGPU kernel and graph matcher implement this subset:
+
+- FP16 or FP32 activations, scales, intermediate projections, and output, all
+ using the same floating type.
+- INT4 base weights and INT8 adapter weights, all with block size 32.
+- Active adapter rank 32, or empty packed adapter weights as described below.
+- Input `X` of rank at least two, positive `K` divisible by 32, and positive `N`.
+ `N` need not be divisible by four; scalar output tails are supported.
+- Implicit zero points: 8 for the base and 128 for the adapters. No bias,
+ explicit zero points, or group indices.
+
+These are implementation limits, not additional schema restrictions. Explicit
+nodes reject unsupported configurations; the transformer leaves unsupported
+patterns unfused. The fused kernel is available only on WebGPU. Both built-in and
+plugin WebGPU can use it; plugin deployments need an ORT host with the schema and
+transformer as well as a provider library with the kernel.
+
+For the current implementation, the active inputs are:
+
+| Input | Storage type | Shape |
+| --- | --- | --- |
+| `X` | FP16 or FP32 | `[..., M, K]` |
+| `W` | Packed INT4 in UINT8 | `[N, K/32, 16]` |
+| `scales` | Same as `X` | `[N, K/32]` or flattened |
+| `lora_A` | Packed INT8 in UINT8 | `[32, K/32, 32]` |
+| `lora_A_scales` | Same as `X` | `[32, K/32]` |
+| `lora_B` | Packed INT8 in UINT8 | `[N, 1, 32]` |
+| `lora_B_scales` | Same as `X` | `[N, 1]` |
+
+Let `A_down` and `B_up` be the logical dequantized matrices of shapes `[K, 32]`
+and `[32, N]`. The adapter bytes represent signed values offset by 128, multiplied
+by their block scales. The computation is:
+
+```text
+base = MatMulNBits(X, W, scales)
+T = X * A_down
+delta = T * B_up
+Y = base + delta
+```
+
+"Down" projects the input features to the adapter rank; "up" projects that rank
+to the output features. `delta` is the adapter's contribution to the output.
+`T` has shape `[..., M, 32]`, and `Y` has shape `[..., M, N]`. Dequantized weights
+and intermediate projection results use the activation type before addition.
+Any LoRA scaling factor must already be represented in the weights/scales; the
+operator has no separate adapter multiplier.
+
+## Automatic graph fusion
+
+[MatMulNBitsQLoRAFusion](../onnxruntime/core/optimizer/matmul_nbits_qlora_fusion.cc)
+runs at `ORT_ENABLE_EXTENDED` or higher when WebGPU provides the fused kernel.
+It matches this graph, accepting either order of the final Add inputs:
+
+```mermaid
+flowchart LR
+ X --> Base["MatMulNBits: INT4 base"]
+ X --> Down["MatMulNBits: INT8 down"]
+ Down --> Up["MatMulNBits: INT8 up"]
+ Base --> Add
+ Up --> Add
+ Add --> Y
+```
+
+All four nodes must be assigned to WebGPU. The down projection must consume the
+same `X` as the base projection. The base, down, and up outputs must each have
+one consumer and must not be graph outputs. Packed shapes must match the layout
+above, with logical dimensions established by the MatMulNBits `K`/`N` attributes.
+Both adapter nodes must use `accuracy_level=0`; the fused node inherits the base
+node's accuracy setting. Bias, explicit zero points, group indices, or prepacked
+weights (`weight_prepacked != 0`) prevent matching.
+
+Fusion reuses the input NodeArgs and preserves initializer shapes, contents, and
+overridability. It does not quantize adapters, compact zero defaults, or match
+arbitrary DequantizeLinear/Cast/MatMul graphs. Such graphs need an offline
+conversion to the packed MatMulNBits representation first.
+
+No QLoRA-specific enable option is needed. To compare with the raw graph, set
+the session configuration entry `optimization.disable_specified_optimizers` to
+`MatMulNBitsQLoRAFusion` (append it with a semicolon if other names are present).
+Disabling the transformer does not expand explicitly exported fused nodes.
+
+## Execution and optimization
+
+[MatMulNBitsQLoRA::ComputeInternal](../onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora.cc)
+selects a path from the input shape, accuracy policy, and device capabilities.
+Here, `M` is the second-to-last dimension of `X`; a one-token prompt uses the
+same selection as decode. Graph fusion does not imply a single GPU dispatch.
+
+| Case | Execution |
+| --- | --- |
+| Either packed adapter is empty | Run ordinary base MatMulNBits, before allocating or dispatching adapter work. |
+| Prefill (`M > 1`) | Compute `T`, then fuse the up projection and addition into the base shader's output stage. |
+| Eligible decode (`M = 1`) | Compute base and down projections in separate workgroups of one dispatch, then run an up-plus-add output shader. |
+| Other decode shapes | Compute down and base projections separately, then run the up-plus-add output shader. |
+
+Packed weights are dequantized where consumed, avoiding full floating-point
+adapter-weight tensors. Prefill retains the rank-sized `T` intermediate while
+avoiding separate base and adapter-delta output tensors. Decode retains separate
+base and `T` outputs; the following dispatch synchronizes them before addition.
+
+### DP4A
+
+The [shared DP4A implementation](../onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_nbits.cc)
+can quantize `X` once and reuse its packed values and scales for the base and
+down projections within an invocation. Its normal eligibility checks, including
+`accuracy_level=4`, still apply. Eligible prefill therefore uses quantization,
+down projection, and base-plus-up dispatches. FP32 short prefill can also share
+this preparation when the base selects DP4A.
+
+The rank-32 down projection uses the existing K-parallel kernel selection:
+`M < 4`, or narrow output (`N <= 32`) with `K >= 2048`. Narrow reductions use
+FP32 accumulation. Other shapes use the existing tiled DP4A kernel; the LoRA
+output stage extends its computation without changing its matrix tiling.
+
+For FP32 decode with `2048 <= K <= 8192` and a DP4A base, the joint kernel adds
+down-projection workgroups to the base dispatch. Both projections read the same
+quantized `X`; up-plus-add is a separate dispatch. Prepared inputs are scoped to
+the invocation and are not reused as cached values across different inputs.
+
+### Subgroup matrix
+
+The [subgroup-matrix implementation](../onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.cc)
+adds the up projection using matrix instructions in the base shader's output
+stage. On a compatible FP16 `8x16x16` configuration, a specialized path packs `X`
+once for both projections, with row padding sufficient for both tile sizes.
+The narrow down projection uses FP32 matrix accumulation and stores `T` in the
+activation type; the base projection then consumes `T` for the fused up stage.
+
+Selection requires supported component types, matrix dimensions, subgroup size,
+and device limits. Choosing a Vulkan backend alone does not guarantee support.
+If the shared-packing specialization is unavailable, normal MatMulNBits selection
+computes the down projection and selects an eligible base-plus-up shader.
+
+### Floating-point decode and shader helpers
+
+For one total input row, `2048 <= K <= 8192`, and a base that does not select
+DP4A, the generic base shader appends four down-projection workgroups. A compatible
+32-lane subgroup path reuses input values in registers and reduces partial sums;
+other devices use workgroup memory. A variable subgroup range requires explicit
+subgroup-size control before requesting 32 lanes.
+
+[GenerateQLoRADown and GenerateQLoRAUp](../onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora.h)
+append reusable WGSL fragments to a caller's shader; they do not dispatch GPU work.
+The [down template](../onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora_down.wgsl.template)
+supplies the generic joint-decode body. The
+[up template](../onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora_up.wgsl.template)
+supplies dequantization and adapter-delta helpers used by fused prefill and the
+separate decode output shader. Subgroup-matrix shaders reuse the dequantization
+helper and implement the multiplication with their own matrix instructions.
+
+## Optional adapters and portable models
+
+Use the raw three-MatMulNBits-plus-Add pattern for a model shared by CPU and
+WebGPU. CPU executes the original operators; WebGPU fuses eligible patterns.
+Saving the optimized graph serializes explicit MatMulNBitsQLoRA nodes, which
+require its WebGPU kernel.
+
+To export a compact adapter-off default, use overridable initializers with these
+shapes and also list them as graph inputs:
+
+| Initializer | Default shape | Contents |
+| --- | --- | --- |
+| `lora_A` | `[0, K/32, 32]` | Empty packed weights |
+| `lora_B` | `[N, 0, 32]` | Empty packed weights |
+| `lora_A_scales` | `[32, K/32]` | Retained scale defaults |
+| `lora_B_scales` | `[N, 1]` | Retained scale defaults |
+
+Declare the packed graph inputs' adapter axes as dynamic: axis 0 for `lora_A`
+and axis 1 for `lora_B`. This allows active rank-32 weights to override the empty
+defaults. Either empty packed weight disables the adapter. Scale-only or
+single-weight overrides therefore retain the base result; both packed weights
+activate the adapter using supplied scales or their defaults. Scale defaults may
+also be empty, but activation then requires supplying their active shapes and data.
+
+This convention relies on empty-weight MatMulNBits support in the raw graph.
+Full-size zero adapter weights still execute arithmetic, including its NaN/Inf
+behavior. The transformer deliberately preserves them instead of converting them
+to empty tensors.
+
+## Numerical behavior, capture, and limitations
+
+`accuracy_level` follows MatMulNBits policy for the base and eligible down
+projections. Level 4 permits DP4A activation quantization, including in an
+optimized down projection whose raw node uses level 0. Quantization, reduction
+order, and accumulation precision can change results relative to the raw graph;
+fusion does not promise bitwise equivalence. `enableMatmulFp32Accumulation`
+controls accumulation in the shaders that honor that option; it does not disable
+activation quantization. Evaluate fused-versus-raw differences separately from
+the change caused by quantizing an originally floating-point adapter.
+
+Graph replay does not re-evaluate the empty-adapter branch. Native callers must
+use distinct `gpu_graph_id` values for adapter-off and adapter-on captures, and
+for other changes to shapes or execution paths. Each capture needs compatible,
+stable GPU bindings and buffer lifetimes. Updating adapter values in the same
+buffers can reuse a capture when shapes, layout, and dispatches remain compatible;
+replacing a buffer or changing the path requires recapture. WebGPU does not derive
+the capture ID from adapter state.
+
+The current QKV/MLP fusions do not compose with MatMulNBitsQLoRA. An empty adapter
+uses standalone base MatMulNBits, so it may have different performance from a
+separate base-only graph that benefits from those fusions. Supporting arbitrary
+active ranks, additional quantization settings, and composed base fusions requires
+further kernel/transformer work; the broader operator schema already allows rank
+and quantization extensions.
+
+## Validation
+
+From a native WebGPU build's test output directory, run:
+
+```text
+onnxruntime_provider_test.exe --gtest_filter="*QLoRA*:*MatMulNBitsInputPreparation*"
+onnxruntime_test_all.exe --gtest_filter="*MatMulNBitsQLoRA*"
+```
+
+[Provider tests](../onnxruntime/test/contrib_ops/matmul_nbits_qlora_test.cc) and
+[fusion tests](../onnxruntime/test/optimizer/matmul_nbits_qlora_fusion_test.cc)
+cover FP16/FP32 numerics, prefill/decode, batching and tails, empty and partial
+adapters, serialization, capture replay, and patterns that must remain unfused.
+Hardware-specific tests skip when the required features are absent; those skips
+do not validate the corresponding shader path. Full-model evaluation should also
+check logits and generated tokens with each intended adapter and capture mode.
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul.wgsl.template b/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul.wgsl.template
index 501a8d1d9dc09..5459a918e44b0 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul.wgsl.template
+++ b/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul.wgsl.template
@@ -5,6 +5,7 @@
#param block_size
#param n_bits
#param has_bias
+#param has_qlora_up
#param has_zero_points
#param is_qualcomm
#param qc_wave128
@@ -13,6 +14,17 @@
#use .getByOffset .setByOffset
+fn write_qlora_output(offset: u32, value: vec4) {
+#if has_qlora_up
+ let row = (offset * 4u) / uniforms.N;
+ let col = (offset * 4u) % uniforms.N;
+ let delta = qlora_delta4(row, col);
+ output.setByOffset(offset, value + delta);
+#else
+ output.setByOffset(offset, value);
+#endif
+}
+
#include "quantization/dp4a_matmul_common.wgsl.template"
// This shader implements co-operative matrix multiply. The key idea here is to
@@ -478,15 +490,15 @@ $MAIN {
acc_element_t(bias[b_global + 14 + b_bias_offset]),
acc_element_t(bias[b_global + 15 + b_bias_offset])
);
- output.setByOffset(output_idx, vec4(vec4(lane_outputs[0], lane_outputs[1], lane_outputs[2], lane_outputs[3]) + bias_vec1));
- output.setByOffset(output_idx+1, vec4(vec4(lane_outputs[4], lane_outputs[5], lane_outputs[6], lane_outputs[7]) + bias_vec2));
- output.setByOffset(output_idx+2, vec4(vec4(lane_outputs[8], lane_outputs[9], lane_outputs[10], lane_outputs[11]) + bias_vec3));
- output.setByOffset(output_idx+3, vec4(vec4(lane_outputs[12], lane_outputs[13], lane_outputs[14], lane_outputs[15]) + bias_vec4));
+ write_qlora_output(output_idx, vec4(vec4(lane_outputs[0], lane_outputs[1], lane_outputs[2], lane_outputs[3]) + bias_vec1));
+ write_qlora_output(output_idx+1, vec4(vec4(lane_outputs[4], lane_outputs[5], lane_outputs[6], lane_outputs[7]) + bias_vec2));
+ write_qlora_output(output_idx+2, vec4(vec4(lane_outputs[8], lane_outputs[9], lane_outputs[10], lane_outputs[11]) + bias_vec3));
+ write_qlora_output(output_idx+3, vec4(vec4(lane_outputs[12], lane_outputs[13], lane_outputs[14], lane_outputs[15]) + bias_vec4));
#else
- output.setByOffset(output_idx, vec4(vec4(lane_outputs[0], lane_outputs[1], lane_outputs[2], lane_outputs[3])));
- output.setByOffset(output_idx+1, vec4(vec4(lane_outputs[4], lane_outputs[5], lane_outputs[6], lane_outputs[7])));
- output.setByOffset(output_idx+2, vec4(vec4(lane_outputs[8], lane_outputs[9], lane_outputs[10], lane_outputs[11])));
- output.setByOffset(output_idx+3, vec4(vec4(lane_outputs[12], lane_outputs[13], lane_outputs[14], lane_outputs[15])));
+ write_qlora_output(output_idx, vec4(vec4(lane_outputs[0], lane_outputs[1], lane_outputs[2], lane_outputs[3])));
+ write_qlora_output(output_idx+1, vec4(vec4(lane_outputs[4], lane_outputs[5], lane_outputs[6], lane_outputs[7])));
+ write_qlora_output(output_idx+2, vec4(vec4(lane_outputs[8], lane_outputs[9], lane_outputs[10], lane_outputs[11])));
+ write_qlora_output(output_idx+3, vec4(vec4(lane_outputs[12], lane_outputs[13], lane_outputs[14], lane_outputs[15])));
#endif
#else
#if has_bias
@@ -517,15 +529,15 @@ $MAIN {
acc_element_t(bias[b_global + 14 + b_bias_offset]),
acc_element_t(bias[b_global + 15 + b_bias_offset])
);
- output.setByOffset(output_idx, vec4(lane_output1 + bias_vec1));
- output.setByOffset(output_idx+1, vec4(lane_output2 + bias_vec2));
- output.setByOffset(output_idx+2, vec4(lane_output3 + bias_vec3));
- output.setByOffset(output_idx+3, vec4(lane_output4 + bias_vec4));
+ write_qlora_output(output_idx, vec4(lane_output1 + bias_vec1));
+ write_qlora_output(output_idx+1, vec4(lane_output2 + bias_vec2));
+ write_qlora_output(output_idx+2, vec4(lane_output3 + bias_vec3));
+ write_qlora_output(output_idx+3, vec4(lane_output4 + bias_vec4));
#else
- output.setByOffset(output_idx, vec4(lane_output1));
- output.setByOffset(output_idx+1, vec4(lane_output2));
- output.setByOffset(output_idx+2, vec4(lane_output3));
- output.setByOffset(output_idx+3, vec4(lane_output4));
+ write_qlora_output(output_idx, vec4(lane_output1));
+ write_qlora_output(output_idx+1, vec4(lane_output2));
+ write_qlora_output(output_idx+2, vec4(lane_output3));
+ write_qlora_output(output_idx+3, vec4(lane_output4));
#endif
#endif
}
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_k_parallel.wgsl.template b/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_k_parallel.wgsl.template
index 2bed945685d16..4f622ffba9276 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_k_parallel.wgsl.template
+++ b/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_k_parallel.wgsl.template
@@ -18,12 +18,27 @@
#param n_bits
#param broadcast_a_row
#param has_zero_points
+#param has_qlora_up
#param has_bias
#param has_weight_idx
#param has_weight_idx_indirect
+#param has_qlora_down
#use .getByOffset .setByOffset
+fn write_qlora_output(offset: u32, value: output_element_t) {
+#if has_qlora_up
+ let row = (offset * 1u) / uniforms.N;
+ let col = (offset * 1u) % uniforms.N;
+ let delta = qlora_delta(row, col);
+ output.setByOffset(offset, value + delta);
+#else
+ output.setByOffset(offset, value);
+#endif
+}
+
+
+
#include "quantization/dp4a_matmul_common.wgsl.template"
const double_tile_size_k_vec = 2 * tile_size_k_vec;
@@ -58,7 +73,51 @@ fn loadSHMA(batch: u32, a_global: u32, kidx_v: u32, col: u32)
}
}
+#if has_qlora_down
+// Eight extra workgroups compute rank 32 from the same quantized activation.
+// The next dispatch consumes both outputs, so no cross-workgroup barrier is needed.
+fn qlora_down(group: u32, local_idx: u32) {
+ let batch = group / 8u;
+ let rank_base = (group % 8u) * 4u;
+ let lane = local_idx % 32u;
+ let rank = local_idx / 32u;
+ var partial = f32(0);
+ for (var start = 0u; start < uniforms.K32; start += 32u) {
+ if (local_idx < 64u) { loadSHMA(batch, 0u, start * 2u, local_idx); }
+ workgroupBarrier();
+ let block = start + lane;
+ if (block < uniforms.K32) {
+ let offset = (rank_base + rank) * uniforms.K32 + block;
+ // XOR maps unsigned INT8 with zero point 128 to signed packed INT8.
+ let w0 = lora_a[offset * 2u] ^ vec4(0x80808080u);
+ let w1 = lora_a[offset * 2u + 1u] ^ vec4(0x80808080u);
+ partial += SDP8AI(tile_A[lane * 2u], w0, tile_A[lane * 2u + 1u], w1,
+ scale_A[lane / 4u] * lora_a_scales[offset]);
+ }
+ workgroupBarrier();
+ }
+ inter_results[rank][lane] = partial;
+ workgroupBarrier();
+ if (local_idx < 4u) {
+ var sum = f32(0);
+ for (var i = 0u; i < 32u; i++) { sum += inter_results[local_idx][i]; }
+ lora_down[batch * 32u + rank_base + local_idx] = sum;
+ }
+}
+#endif
+
$MAIN {
+#if has_qlora_down
+ let base_groups = uniforms.batch_count * uniforms.dispatch_M * uniforms.num_N_tile;
+ // Normalization may pad the dispatch beyond the eight adapter groups per batch.
+ if (workgroup_idx >= base_groups + uniforms.batch_count * 8u) {
+ return;
+ }
+ if (workgroup_idx >= base_groups) {
+ qlora_down(workgroup_idx - base_groups, local_idx);
+ return;
+ }
+#endif
let batch = workgroup_idx / (uniforms.dispatch_M * uniforms.num_N_tile);
if (batch >= uniforms.batch_count) {
return;
@@ -207,9 +266,9 @@ $MAIN {
#if has_bias
let bias_value = acc_element_t(bias[b_global + b_bias_offset]);
// Downcast to the output element type only at the final store.
- output.setByOffset(output_idx, output_element_t(output_value + bias_value));
+ write_qlora_output(output_idx, output_element_t(output_value + bias_value));
#else
- output.setByOffset(output_idx, output_element_t(output_value));
+ write_qlora_output(output_idx, output_element_t(output_value));
#endif
}
}
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_nbits.cc b/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_nbits.cc
index 2a218752b9b45..bfe80bb044b71 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_nbits.cc
+++ b/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_nbits.cc
@@ -3,6 +3,7 @@
#include "contrib_ops/webgpu/quantization/dp4a_matmul_nbits.h"
#include "contrib_ops/webgpu/quantization/matmul_nbits_common.h"
+#include "contrib_ops/webgpu/quantization/matmul_nbits_qlora.h"
#include "core/providers/webgpu/shader_helper.h"
namespace onnxruntime {
@@ -19,7 +20,8 @@ Status DP4AMatMulQuantizeProgram::GenerateShaderCode(ShaderHelper& shader) const
}
Status DP4AMatMulNBitsProgram::GenerateShaderCode(ShaderHelper& shader) const {
- const auto& a = shader.AddInput("input_a", ShaderUsage::UseUniform | ShaderUsage::UseIndicesTypeAlias | ShaderUsage::UseValueTypeAlias);
+ const auto& a = shader.AddInput(
+ "input_a", ShaderUsage::UseUniform | ShaderUsage::UseIndicesTypeAlias | ShaderUsage::UseValueTypeAlias);
const auto& scales_a = shader.AddInput("scales_a", ShaderUsage::UseUniform);
const auto& b = shader.AddInput("input_b", ShaderUsage::UseUniform);
const auto& scales_b = shader.AddInput("scales_b", ShaderUsage::UseUniform);
@@ -32,23 +34,23 @@ Status DP4AMatMulNBitsProgram::GenerateShaderCode(ShaderHelper& shader) const {
if (has_weight_idx_indirect_) {
shader.AddInput("weight_index_indirect", ShaderUsage::UseUniform);
}
+ if (has_qlora_up_) {
+ shader.AddInput("lora_t");
+ shader.AddInput("lora_b");
+ shader.AddInput("lora_b_scales");
+ ORT_RETURN_IF_ERROR(GenerateQLoRAUp(shader, acc_f32_, true));
+ }
const auto& output = shader.AddOutput("output", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias);
- return WGSL_TEMPLATE_APPLY(shader, "quantization/dp4a_matmul.wgsl.template",
- WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32_),
- WGSL_TEMPLATE_PARAMETER(block_size, block_size_),
- WGSL_TEMPLATE_PARAMETER(has_bias, has_bias_),
- WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx_),
- WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect_),
- WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points_),
- WGSL_TEMPLATE_PARAMETER(is_qualcomm, is_qualcomm_),
- WGSL_TEMPLATE_PARAMETER(n_bits, nbits_),
- WGSL_TEMPLATE_PARAMETER(output_type_i32, true),
- WGSL_TEMPLATE_PARAMETER(qc_wave128, SubgroupSize() == 128),
- WGSL_TEMPLATE_VARIABLE(a, a),
- WGSL_TEMPLATE_VARIABLE(b, b),
- WGSL_TEMPLATE_VARIABLE(output, output),
- WGSL_TEMPLATE_VARIABLE(scales_a, scales_a),
- WGSL_TEMPLATE_VARIABLE(scales_b, scales_b));
+ return WGSL_TEMPLATE_APPLY(
+ shader, "quantization/dp4a_matmul.wgsl.template", WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32_),
+ WGSL_TEMPLATE_PARAMETER(block_size, block_size_), WGSL_TEMPLATE_PARAMETER(has_bias, has_bias_),
+ WGSL_TEMPLATE_PARAMETER(has_qlora_up, has_qlora_up_), WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx_),
+ WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect_),
+ WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points_), WGSL_TEMPLATE_PARAMETER(is_qualcomm, is_qualcomm_),
+ WGSL_TEMPLATE_PARAMETER(n_bits, nbits_), WGSL_TEMPLATE_PARAMETER(output_type_i32, true),
+ WGSL_TEMPLATE_PARAMETER(qc_wave128, SubgroupSize() == 128),
+ WGSL_TEMPLATE_VARIABLE(a, a), WGSL_TEMPLATE_VARIABLE(b, b), WGSL_TEMPLATE_VARIABLE(output, output),
+ WGSL_TEMPLATE_VARIABLE(scales_a, scales_a), WGSL_TEMPLATE_VARIABLE(scales_b, scales_b));
}
// scale_A components = 1, b components = 4, output components = 1
@@ -66,47 +68,54 @@ Status DP4AMatMulNBitsKParallelProgram::GenerateShaderCode(ShaderHelper& shader)
if (has_weight_idx_indirect_) {
shader.AddInput("weight_index_indirect", ShaderUsage::UseUniform);
}
+ if (has_qlora_up_) {
+ shader.AddInput("lora_t");
+ shader.AddInput("lora_b");
+ shader.AddInput("lora_b_scales");
+ ORT_RETURN_IF_ERROR(GenerateQLoRAUp(shader, acc_f32_));
+ }
const auto& output = shader.AddOutput("output", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias);
- ORT_ENFORCE(WorkgroupSizeX() % tile_size_k_vec_ == 0 && tile_size_k_vec_ % 4 == 0, "tile_size_k_vec_ must evenly divide workgroup size X and be divisible by 4");
+ ORT_ENFORCE(WorkgroupSizeX() % tile_size_k_vec_ == 0 && tile_size_k_vec_ % 4 == 0,
+ "tile_size_k_vec_ must evenly divide workgroup size X and be divisible by 4");
const uint32_t sub_tile_count = WorkgroupSizeX() / tile_size_k_vec_;
ORT_ENFORCE(tile_size_ % sub_tile_count == 0, "tile_size_ must be divisible by sub_tile_count");
- return WGSL_TEMPLATE_APPLY(shader, "quantization/dp4a_matmul_k_parallel.wgsl.template",
- WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32_),
- WGSL_TEMPLATE_PARAMETER(broadcast_a_row, broadcast_a_row_),
- WGSL_TEMPLATE_PARAMETER(has_bias, has_bias_),
- WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx_),
- WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect_),
- WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points_),
- WGSL_TEMPLATE_PARAMETER(n_bits, nbits_),
- WGSL_TEMPLATE_PARAMETER(output_type_i32, true),
- WGSL_TEMPLATE_PARAMETER(single_scale_weights, single_scale_weights_),
- WGSL_TEMPLATE_PARAMETER(sub_tile_count, sub_tile_count),
- WGSL_TEMPLATE_PARAMETER(tile_size, tile_size_),
- WGSL_TEMPLATE_PARAMETER(tile_size_k_vec, tile_size_k_vec_),
- WGSL_TEMPLATE_VARIABLE(a, a),
- WGSL_TEMPLATE_VARIABLE(b, b),
- WGSL_TEMPLATE_VARIABLE(output, output),
- WGSL_TEMPLATE_VARIABLE(scales_a, scales_a),
- WGSL_TEMPLATE_VARIABLE(scales_b, scales_b));
+ if (has_qlora_down_) {
+ shader.AddInput("lora_a");
+ shader.AddInput("lora_a_scales");
+ shader.AddOutput("lora_down");
+ }
+
+ return WGSL_TEMPLATE_APPLY(
+ shader, "quantization/dp4a_matmul_k_parallel.wgsl.template", WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32_),
+ WGSL_TEMPLATE_PARAMETER(broadcast_a_row, broadcast_a_row_), WGSL_TEMPLATE_PARAMETER(has_bias, has_bias_),
+ WGSL_TEMPLATE_PARAMETER(has_qlora_down, has_qlora_down_), WGSL_TEMPLATE_PARAMETER(has_qlora_up, has_qlora_up_),
+ WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx_),
+ WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect_),
+ WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points_), WGSL_TEMPLATE_PARAMETER(n_bits, nbits_),
+ WGSL_TEMPLATE_PARAMETER(output_type_i32, true),
+ WGSL_TEMPLATE_PARAMETER(single_scale_weights, single_scale_weights_),
+ WGSL_TEMPLATE_PARAMETER(sub_tile_count, sub_tile_count), WGSL_TEMPLATE_PARAMETER(tile_size, tile_size_),
+ WGSL_TEMPLATE_PARAMETER(tile_size_k_vec, tile_size_k_vec_), WGSL_TEMPLATE_VARIABLE(a, a),
+ WGSL_TEMPLATE_VARIABLE(b, b), WGSL_TEMPLATE_VARIABLE(output, output), WGSL_TEMPLATE_VARIABLE(scales_a, scales_a),
+ WGSL_TEMPLATE_VARIABLE(scales_b, scales_b));
}
-Status ApplyDP4AMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales,
- const Tensor* zero_points, const Tensor* bias,
- uint32_t batch_count,
- uint32_t M,
- uint32_t dispatch_M,
- uint32_t N,
- uint32_t K,
- uint32_t block_size,
- uint32_t zero_blocks_per_col,
- uint32_t min_M_for_tile_optimization,
- uint32_t nbits,
- onnxruntime::webgpu::ComputeContext& context,
- Tensor* y,
- const uint32_t weight_index,
- const Tensor* weight_index_indirect, DP4AQuantizedInput* quantized_input) {
+Status ApplyDP4AMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales, const Tensor* zero_points,
+ const Tensor* bias, uint32_t batch_count, uint32_t M, uint32_t dispatch_M, uint32_t N,
+ uint32_t K, uint32_t block_size, uint32_t zero_blocks_per_col,
+ uint32_t min_M_for_tile_optimization, uint32_t nbits,
+ onnxruntime::webgpu::ComputeContext& context, Tensor* y, const uint32_t weight_index,
+ const Tensor* weight_index_indirect, DP4AQuantizedInput* quantized_input,
+ const Tensor* lora_t, const Tensor* lora_b,
+ const Tensor* lora_at, Tensor* lora_down, const Tensor* lora_b_scales,
+ const Tensor* lora_a_scales) {
+ const bool has_qlora_down = lora_at != nullptr;
+ ORT_RETURN_IF_NOT(!has_qlora_down || (lora_a_scales && lora_down && a->IsDataType() && nbits == 4 &&
+ block_size == 32 && M == 1 && dispatch_M == 1 &&
+ !weight_index && !weight_index_indirect && !zero_points && !bias && !lora_t),
+ "Unsupported joint DP4A QLoRA decode projection.");
constexpr uint32_t kVec4Components = 4;
constexpr uint32_t kVec2Components = 2;
constexpr uint32_t kU32Components = 4;
@@ -134,6 +143,7 @@ Status ApplyDP4AMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Tensor
const auto& a_quant = *input.values;
const auto& a_scale = *input.scales;
+ const bool has_qlora_up = lora_t != nullptr;
const bool has_zero_points = zero_points != nullptr;
const bool has_bias = bias != nullptr;
const bool has_weight_idx_indirect = weight_index_indirect != nullptr;
@@ -157,10 +167,10 @@ Status ApplyDP4AMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Tensor
const bool acc_f32 = small_n || context.EnableMatmulFp32Accumulation();
DP4AMatMulNBitsKParallelProgram mul_program{
tile_size_k_vec, tile_size_n, nbits, has_zero_points, has_bias, has_weight_idx,
- has_weight_idx_indirect, single_scale_weights, broadcast_a, acc_f32};
+ has_weight_idx_indirect, single_scale_weights, broadcast_a, acc_f32, has_qlora_up, has_qlora_down};
uint32_t num_N_tile = (N + tile_size_n - 1) / tile_size_n;
mul_program.SetWorkgroupSize(128);
- mul_program.SetDispatchGroupSize(batch_count * dispatch_M * num_N_tile);
+ mul_program.SetDispatchGroupSize(batch_count * dispatch_M * num_N_tile + (has_qlora_down ? batch_count * 8 : 0));
mul_program
.AddInputs({{&a_quant, ProgramTensorMetadataDependency::TypeAndRank, static_cast(kVec4Components)},
{&a_scale, ProgramTensorMetadataDependency::TypeAndRank, 1},
@@ -170,7 +180,7 @@ Status ApplyDP4AMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Tensor
weight_index, dispatch_M})
.AddOutput({y, ProgramTensorMetadataDependency::TypeAndRank, 1})
.CacheHint(nbits, tile_size_k_vec, tile_size_n, has_zero_points, single_scale_weights, has_bias, has_weight_idx,
- has_weight_idx_indirect, broadcast_a, acc_f32);
+ has_weight_idx_indirect, broadcast_a, acc_f32, has_qlora_up, has_qlora_down);
if (has_zero_points) {
mul_program.AddInput(
{zero_points, ProgramTensorMetadataDependency::None, {(zero_points->Shape().Size() + 3) / 4}, 4});
@@ -181,6 +191,16 @@ Status ApplyDP4AMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Tensor
if (has_weight_idx_indirect) {
mul_program.AddInput({weight_index_indirect, ProgramTensorMetadataDependency::None});
}
+ if (has_qlora_up) {
+ mul_program.AddInput({lora_t, ProgramTensorMetadataDependency::Type, 4});
+ mul_program.AddInput({lora_b, ProgramTensorMetadataDependency::Type, 16});
+ mul_program.AddInput({lora_b_scales, ProgramTensorMetadataDependency::Type});
+ }
+ if (has_qlora_down) {
+ mul_program.AddInput({lora_at, ProgramTensorMetadataDependency::Type, 16});
+ mul_program.AddInput({lora_a_scales, ProgramTensorMetadataDependency::Type});
+ mul_program.AddOutput({lora_down, ProgramTensorMetadataDependency::Type});
+ }
return context.RunProgram(mul_program);
}
@@ -197,16 +217,19 @@ Status ApplyDP4AMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Tensor
uint32_t num_N_tile = (N + tile_n - 1) / tile_n;
bool is_qualcomm = context.AdapterInfo().vendor == std::string_view{"qualcomm"};
const bool acc_f32 = context.EnableMatmulFp32Accumulation();
- DP4AMatMulNBitsProgram mul_program{block_size, nbits, has_zero_points, has_bias, has_weight_idx, has_weight_idx_indirect, is_qualcomm, acc_f32};
+ DP4AMatMulNBitsProgram mul_program{
+ block_size, nbits, has_zero_points,
+ has_bias, has_weight_idx, has_weight_idx_indirect, is_qualcomm, acc_f32, has_qlora_up};
mul_program.SetWorkgroupSize(256);
if (qc_wave128) {
mul_program.SetSubgroupSize(128);
}
mul_program.SetDispatchGroupSize(batch_count * num_M_tile * num_N_tile);
- mul_program.AddInputs({{&a_quant, ProgramTensorMetadataDependency::TypeAndRank, static_cast(kVec4Components)},
- {&a_scale, ProgramTensorMetadataDependency::TypeAndRank, 1},
- {b, ProgramTensorMetadataDependency::TypeAndRank, static_cast((nbits / 2) * kU32Components)},
- {scales, ProgramTensorMetadataDependency::TypeAndRank, 1}})
+ mul_program
+ .AddInputs({{&a_quant, ProgramTensorMetadataDependency::TypeAndRank, static_cast(kVec4Components)},
+ {&a_scale, ProgramTensorMetadataDependency::TypeAndRank, 1},
+ {b, ProgramTensorMetadataDependency::TypeAndRank, static_cast((nbits / 2) * kU32Components)},
+ {scales, ProgramTensorMetadataDependency::TypeAndRank, 1}})
.AddUniformVariables({{static_cast(batch_count)},
{static_cast(M)},
{static_cast(N)},
@@ -218,9 +241,12 @@ Status ApplyDP4AMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Tensor
{zero_blocks_per_col},
{weight_index}})
.AddOutput({y, ProgramTensorMetadataDependency::TypeAndRank, reshaped_y_shape, static_cast(kVec4Components)})
- .CacheHint("Block" + std::to_string(block_size), nbits, has_zero_points, is_qualcomm, has_bias, has_weight_idx, has_weight_idx_indirect, acc_f32);
+ .CacheHint("Block" + std::to_string(block_size), nbits, has_zero_points, is_qualcomm,
+ has_bias, has_weight_idx, has_weight_idx_indirect, acc_f32,
+ has_qlora_up);
if (has_zero_points) {
- mul_program.AddInput({zero_points, ProgramTensorMetadataDependency::None, {(zero_points->Shape().Size() + 3) / 4}, 4});
+ mul_program.AddInput(
+ {zero_points, ProgramTensorMetadataDependency::None, {(zero_points->Shape().Size() + 3) / 4}, 4});
}
if (has_bias) {
mul_program.AddInput({bias, ProgramTensorMetadataDependency::None});
@@ -228,26 +254,23 @@ Status ApplyDP4AMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Tensor
if (has_weight_idx_indirect) {
mul_program.AddInput({weight_index_indirect, ProgramTensorMetadataDependency::None});
}
+ if (has_qlora_up) {
+ mul_program.AddInput({lora_t, ProgramTensorMetadataDependency::Type, 4});
+ mul_program.AddInput({lora_b, ProgramTensorMetadataDependency::Type, 16});
+ mul_program.AddInput({lora_b_scales, ProgramTensorMetadataDependency::Type});
+ }
return context.RunProgram(mul_program);
}
-bool CanApplyDP4AMatrixMatMulNBits(onnxruntime::webgpu::ComputeContext& context,
- uint64_t accuracy_level,
- uint32_t block_size,
- uint32_t N,
- uint32_t K,
- uint32_t components_k,
- uint32_t M,
- bool has_weight_idx_indirect,
- const Tensor* y) {
+bool CanApplyDP4AMatrixMatMulNBits(onnxruntime::webgpu::ComputeContext& context, uint64_t accuracy_level,
+ uint32_t block_size, uint32_t N, uint32_t K, uint32_t components_k, uint32_t M,
+ bool has_weight_idx_indirect, const Tensor* y) {
// macOS - Avoid using dp4a on Metal, as it does not appear to have native dp4a support.
// https://github.com/gpuweb/gpuweb/issues/2677#issuecomment-1713292226
// Use 'vendor' to check for metal; 'backend' is always WEBGPU when running under wasm.
- bool use_dp4a = context.HasFeature(wgpu::FeatureName::Subgroups) &&
- context.AdapterInfo().vendor != std::string_view{"apple"};
- if (!(accuracy_level == 4 && block_size % 32 == 0 &&
- components_k == 4 && K % 128 == 0 && N % 16 == 0 &&
- use_dp4a)) {
+ bool use_dp4a =
+ context.HasFeature(wgpu::FeatureName::Subgroups) && context.AdapterInfo().vendor != std::string_view{"apple"};
+ if (!(accuracy_level == 4 && block_size % 32 == 0 && components_k == 4 && K % 128 == 0 && N % 16 == 0 && use_dp4a)) {
return false;
}
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_nbits.h b/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_nbits.h
index e627fc7d60dcd..03d06cdf1b61a 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_nbits.h
+++ b/onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_nbits.h
@@ -30,28 +30,24 @@ class DP4AMatMulQuantizeProgram final : public Program {
public:
- DP4AMatMulNBitsProgram(uint32_t block_size, uint32_t nbits,
- bool has_zero_points, bool has_bias,
- bool has_weight_idx, bool has_weight_idx_indirect, bool is_qualcomm,
- bool acc_f32) : Program{"DP4AMatMulNBits"},
- block_size_(block_size),
- nbits_(nbits),
- has_bias_(has_bias),
- has_zero_points_(has_zero_points),
- has_weight_idx_(has_weight_idx),
- has_weight_idx_indirect_(has_weight_idx_indirect),
- is_qualcomm_(is_qualcomm),
- acc_f32_(acc_f32) {}
+ DP4AMatMulNBitsProgram(uint32_t block_size, uint32_t nbits, bool has_zero_points, bool has_bias, bool has_weight_idx,
+ bool has_weight_idx_indirect, bool is_qualcomm, bool acc_f32, bool has_qlora_up = false)
+ : Program{has_qlora_up ? "DP4AMatMulNBitsQLoRAUp" : "DP4AMatMulNBits"},
+ block_size_(block_size),
+ nbits_(nbits),
+ has_bias_(has_bias),
+ has_zero_points_(has_zero_points),
+ has_weight_idx_(has_weight_idx),
+ has_weight_idx_indirect_(has_weight_idx_indirect),
+ is_qualcomm_(is_qualcomm),
+ acc_f32_(acc_f32),
+ has_qlora_up_(has_qlora_up) {}
Status GenerateShaderCode(ShaderHelper& sh) const override;
WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES(
- {"batch_count", ProgramUniformVariableDataType::Uint32},
- {"M", ProgramUniformVariableDataType::Uint32},
- {"N", ProgramUniformVariableDataType::Uint32},
- {"K", ProgramUniformVariableDataType::Uint32},
- {"K8", ProgramUniformVariableDataType::Uint32},
- {"K16", ProgramUniformVariableDataType::Uint32},
- {"num_M_tile", ProgramUniformVariableDataType::Uint32},
- {"num_N_tile", ProgramUniformVariableDataType::Uint32},
+ {"batch_count", ProgramUniformVariableDataType::Uint32}, {"M", ProgramUniformVariableDataType::Uint32},
+ {"N", ProgramUniformVariableDataType::Uint32}, {"K", ProgramUniformVariableDataType::Uint32},
+ {"K8", ProgramUniformVariableDataType::Uint32}, {"K16", ProgramUniformVariableDataType::Uint32},
+ {"num_M_tile", ProgramUniformVariableDataType::Uint32}, {"num_N_tile", ProgramUniformVariableDataType::Uint32},
{"zero_blocks_per_col", ProgramUniformVariableDataType::Uint32},
{"weight_idx", ProgramUniformVariableDataType::Uint32});
@@ -64,38 +60,38 @@ class DP4AMatMulNBitsProgram final : public Program {
bool has_weight_idx_indirect_;
bool is_qualcomm_;
bool acc_f32_;
+ bool has_qlora_up_;
};
class DP4AMatMulNBitsKParallelProgram final : public Program {
public:
- DP4AMatMulNBitsKParallelProgram(uint32_t tile_size_k_vec, uint32_t tile_size, uint32_t nbits,
- bool has_zero_points, bool has_bias,
- bool has_weight_idx, bool has_weight_idx_indirect, bool single_scale_weights,
- bool broadcast_a_row = false,
- bool acc_f32 = false) : Program{"DP4AMatMulNBitsKParallelProgram"},
- tile_size_k_vec_(tile_size_k_vec),
- tile_size_(tile_size),
- nbits_(nbits),
- has_bias_(has_bias),
- has_zero_points_(has_zero_points),
- has_weight_idx_(has_weight_idx),
- has_weight_idx_indirect_(has_weight_idx_indirect),
- single_scale_weights_(single_scale_weights),
- broadcast_a_row_(broadcast_a_row),
- acc_f32_(acc_f32) {}
+ DP4AMatMulNBitsKParallelProgram(uint32_t tile_size_k_vec, uint32_t tile_size, uint32_t nbits, bool has_zero_points,
+ bool has_bias, bool has_weight_idx, bool has_weight_idx_indirect,
+ bool single_scale_weights, bool broadcast_a_row = false, bool acc_f32 = false,
+ bool has_qlora_up = false, bool has_qlora_down = false)
+ : Program{has_qlora_down ? "DP4AMatMulNBitsQLoRADownKParallel"
+ : has_qlora_up ? "DP4AMatMulNBitsQLoRAUpKParallel"
+ : "DP4AMatMulNBitsKParallelProgram"},
+ tile_size_k_vec_(tile_size_k_vec),
+ tile_size_(tile_size),
+ nbits_(nbits),
+ has_bias_(has_bias),
+ has_zero_points_(has_zero_points),
+ has_weight_idx_(has_weight_idx),
+ has_weight_idx_indirect_(has_weight_idx_indirect),
+ single_scale_weights_(single_scale_weights),
+ broadcast_a_row_(broadcast_a_row),
+ acc_f32_(acc_f32),
+ has_qlora_up_(has_qlora_up),
+ has_qlora_down_(has_qlora_down) {}
Status GenerateShaderCode(ShaderHelper& sh) const override;
WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES(
- {"batch_count", ProgramUniformVariableDataType::Uint32},
- {"M", ProgramUniformVariableDataType::Uint32},
- {"N", ProgramUniformVariableDataType::Uint32},
- {"K", ProgramUniformVariableDataType::Uint32},
- {"K16", ProgramUniformVariableDataType::Uint32},
- {"K32", ProgramUniformVariableDataType::Uint32},
- {"block_size", ProgramUniformVariableDataType::Uint32},
- {"num_N_tile", ProgramUniformVariableDataType::Uint32},
+ {"batch_count", ProgramUniformVariableDataType::Uint32}, {"M", ProgramUniformVariableDataType::Uint32},
+ {"N", ProgramUniformVariableDataType::Uint32}, {"K", ProgramUniformVariableDataType::Uint32},
+ {"K16", ProgramUniformVariableDataType::Uint32}, {"K32", ProgramUniformVariableDataType::Uint32},
+ {"block_size", ProgramUniformVariableDataType::Uint32}, {"num_N_tile", ProgramUniformVariableDataType::Uint32},
{"zero_blocks_per_col", ProgramUniformVariableDataType::Uint32},
- {"weight_idx", ProgramUniformVariableDataType::Uint32},
- {"dispatch_M", ProgramUniformVariableDataType::Uint32});
+ {"weight_idx", ProgramUniformVariableDataType::Uint32}, {"dispatch_M", ProgramUniformVariableDataType::Uint32});
private:
uint32_t tile_size_k_vec_;
@@ -108,24 +104,20 @@ class DP4AMatMulNBitsKParallelProgram final : public Program())
- .TypeConstraint("T3", DataTypeImpl::GetTensorType())
- .TypeConstraint("T4", DataTypeImpl::GetTensorType()),
- MatMulNBits);
+ONNX_OPERATOR_KERNEL_EX(MatMulNBits, kMSDomain, 1, kWebGpuExecutionProvider,
+ (*KernelDefBuilder::Create())
+ .TypeConstraint("T1", WebGpuSupportedFloatTypes())
+ .TypeConstraint("T2", DataTypeImpl::GetTensorType())
+ .TypeConstraint("T3", DataTypeImpl::GetTensorType())
+ .TypeConstraint("T4", DataTypeImpl::GetTensorType()),
+ MatMulNBits);
Status MatMulNBitsWideTileProgram::GenerateShaderCode(ShaderHelper& shader) const {
const auto& a = shader.AddInput("input_a", ShaderUsage::UseValueTypeAlias | ShaderUsage::UseElementTypeAlias);
@@ -45,6 +42,12 @@ Status MatMulNBitsWideTileProgram::GenerateShaderCode(ShaderHelper& shader) cons
if (has_weight_idx_indirect_) {
shader.AddInput("weight_index_indirect", ShaderUsage::UseUniform);
}
+ if (has_qlora_up_) {
+ shader.AddInput("lora_t", ShaderUsage::UseElementTypeAlias);
+ shader.AddInput("lora_b", ShaderUsage::UseElementTypeAlias);
+ shader.AddInput("lora_b_scales");
+ ORT_RETURN_IF_ERROR(GenerateQLoRAUp(shader, acc_f32_));
+ }
const auto& output = shader.AddOutput("output", ShaderUsage::UseValueTypeAlias | ShaderUsage::UseElementTypeAlias);
const uint32_t workgroup_size = WorkgroupSizeX() * WorkgroupSizeY() * WorkgroupSizeZ();
@@ -52,20 +55,15 @@ Status MatMulNBitsWideTileProgram::GenerateShaderCode(ShaderHelper& shader) cons
ORT_ENFORCE(tile_m_ % (workgroup_size / 8) == 0, "tile_m must be a multiple of workgroup_size / 8.");
ORT_ENFORCE(nbits_ == 4 || nbits_ == 8, "Only 4/8 bits are supported for webgpu matmulnbits.");
- return WGSL_TEMPLATE_APPLY(shader, "quantization/matmul_nbits_wide_tile.wgsl.template",
- WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32_),
- WGSL_TEMPLATE_PARAMETER(has_bias, has_bias_),
- WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx_),
- WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect_),
- WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points_),
- WGSL_TEMPLATE_PARAMETER(nbits, nbits_),
- WGSL_TEMPLATE_PARAMETER(subgroup_min_size, subgroup_min_size_),
- WGSL_TEMPLATE_PARAMETER(tile_m, tile_m_),
- WGSL_TEMPLATE_PARAMETER(tile_n, tile_n_),
- WGSL_TEMPLATE_VARIABLE(a, a),
- WGSL_TEMPLATE_VARIABLE(b, b),
- WGSL_TEMPLATE_VARIABLE(output, output),
- WGSL_TEMPLATE_VARIABLE(scales, scales));
+ return WGSL_TEMPLATE_APPLY(
+ shader, "quantization/matmul_nbits_wide_tile.wgsl.template", WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32_),
+ WGSL_TEMPLATE_PARAMETER(has_bias, has_bias_), WGSL_TEMPLATE_PARAMETER(has_qlora_up, has_qlora_up_),
+ WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx_),
+ WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect_),
+ WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points_), WGSL_TEMPLATE_PARAMETER(nbits, nbits_),
+ WGSL_TEMPLATE_PARAMETER(subgroup_min_size, subgroup_min_size_), WGSL_TEMPLATE_PARAMETER(tile_m, tile_m_),
+ WGSL_TEMPLATE_PARAMETER(tile_n, tile_n_), WGSL_TEMPLATE_VARIABLE(a, a), WGSL_TEMPLATE_VARIABLE(b, b),
+ WGSL_TEMPLATE_VARIABLE(output, output), WGSL_TEMPLATE_VARIABLE(scales, scales));
}
// Uses the same K-parallel reduction strategy as DP4AMatMulNBitsKParallelProgram.
@@ -82,7 +80,21 @@ Status MatMulNBitsProgram::GenerateShaderCode(ShaderHelper& shader) const {
if (has_weight_idx_indirect_) {
shader.AddInput("weight_index_indirect", ShaderUsage::UseUniform);
}
+ if (has_qlora_up_) {
+ shader.AddInput("lora_t", ShaderUsage::UseElementTypeAlias);
+ shader.AddInput("lora_b", ShaderUsage::UseElementTypeAlias);
+ shader.AddInput("lora_b_scales");
+ ORT_RETURN_IF_ERROR(GenerateQLoRAUp(shader, acc_f32_));
+ }
+ if (has_qlora_down_) {
+ shader.AddInput("lora_at");
+ shader.AddInput("lora_a_scales");
+ }
const auto& output = shader.AddOutput("output", ShaderUsage::UseElementTypeAlias);
+ if (has_qlora_down_) {
+ shader.AddOutput("lora_down", ShaderUsage::UseElementTypeAlias);
+ ORT_RETURN_IF_ERROR(GenerateQLoRADown(shader, a, acc_f32_, qlora_down_subgroups_));
+ }
const uint32_t components_a = a.NumComponents();
const uint32_t components_b = b.NumComponents() / 4; // b is stored as uint32 which includes 4 uint8.
@@ -92,28 +104,20 @@ Status MatMulNBitsProgram::GenerateShaderCode(ShaderHelper& shader) const {
const uint32_t a_length_per_tile = tile_size_k / components_a;
uint32_t sub_tile_count = WorkgroupSizeX() / tile_size_k_vec;
- return WGSL_TEMPLATE_APPLY(shader, "quantization/matmul_nbits.wgsl.template",
- WGSL_TEMPLATE_PARAMETER(a_length_per_tile, a_length_per_tile),
- WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32_),
- WGSL_TEMPLATE_PARAMETER(broadcast_a_row, broadcast_a_row_),
- WGSL_TEMPLATE_PARAMETER(component_a, components_a),
- WGSL_TEMPLATE_PARAMETER(component_b, components_b),
- WGSL_TEMPLATE_PARAMETER(elements_in_value_b, elements_in_value_b),
- WGSL_TEMPLATE_PARAMETER(has_bias, has_bias_),
- WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx_),
- WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect_),
- WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points_),
- WGSL_TEMPLATE_PARAMETER(n_bits, nbits_),
- WGSL_TEMPLATE_PARAMETER(output_type_i32, false),
- WGSL_TEMPLATE_PARAMETER(single_scale_weights, single_scale_weights_),
- WGSL_TEMPLATE_PARAMETER(sub_tile_count, sub_tile_count),
- WGSL_TEMPLATE_PARAMETER(tile_size, tile_size_),
- WGSL_TEMPLATE_PARAMETER(tile_size_k, tile_size_k),
- WGSL_TEMPLATE_PARAMETER(tile_size_k_vec, tile_size_k_vec),
- WGSL_TEMPLATE_VARIABLE(a, a),
- WGSL_TEMPLATE_VARIABLE(b, b),
- WGSL_TEMPLATE_VARIABLE(output, output),
- WGSL_TEMPLATE_VARIABLE(scales_b, scales_b));
+ return WGSL_TEMPLATE_APPLY(
+ shader, "quantization/matmul_nbits.wgsl.template", WGSL_TEMPLATE_PARAMETER(a_length_per_tile, a_length_per_tile),
+ WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32_), WGSL_TEMPLATE_PARAMETER(broadcast_a_row, broadcast_a_row_),
+ WGSL_TEMPLATE_PARAMETER(component_a, components_a), WGSL_TEMPLATE_PARAMETER(component_b, components_b),
+ WGSL_TEMPLATE_PARAMETER(elements_in_value_b, elements_in_value_b), WGSL_TEMPLATE_PARAMETER(has_bias, has_bias_),
+ WGSL_TEMPLATE_PARAMETER(has_qlora_up, has_qlora_up_), WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx_),
+ WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect_),
+ WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points_), WGSL_TEMPLATE_PARAMETER(n_bits, nbits_),
+ WGSL_TEMPLATE_PARAMETER(output_type_i32, false),
+ WGSL_TEMPLATE_PARAMETER(single_scale_weights, single_scale_weights_),
+ WGSL_TEMPLATE_PARAMETER(sub_tile_count, sub_tile_count), WGSL_TEMPLATE_PARAMETER(tile_size, tile_size_),
+ WGSL_TEMPLATE_PARAMETER(tile_size_k, tile_size_k), WGSL_TEMPLATE_PARAMETER(tile_size_k_vec, tile_size_k_vec),
+ WGSL_TEMPLATE_VARIABLE(a, a), WGSL_TEMPLATE_VARIABLE(b, b), WGSL_TEMPLATE_VARIABLE(output, output),
+ WGSL_TEMPLATE_VARIABLE(scales_b, scales_b));
}
bool CanApplyWideTileMatMulNBits(uint32_t M,
@@ -147,11 +151,13 @@ Status ApplyWideTileMatMulNBits(const Tensor* a, const Tensor* b, const Tensor*
uint32_t batch_count, uint32_t dispatch_M, uint32_t N, uint32_t K,
uint32_t block_size, uint32_t zero_blocks_per_col, uint32_t nbits,
onnxruntime::webgpu::ComputeContext& context, Tensor* y,
- uint32_t weight_index, const Tensor* weight_index_indirect) {
+ uint32_t weight_index, const Tensor* weight_index_indirect,
+ const Tensor* lora_t, const Tensor* lora_b, const Tensor* lora_b_scales) {
const bool has_zero_points = zero_points != nullptr;
const bool has_bias = bias != nullptr;
const bool has_weight_idx_indirect = weight_index_indirect != nullptr;
const bool has_weight_idx = weight_index > 0 || has_weight_idx_indirect;
+ const bool has_qlora_up = lora_t != nullptr;
const bool acc_f32 = context.EnableMatmulFp32Accumulation();
const uint32_t block_size_per_col = block_size == K * N ? K : block_size;
const uint32_t n_blocks_per_col = CeilDiv(K, block_size_per_col);
@@ -177,7 +183,7 @@ Status ApplyWideTileMatMulNBits(const Tensor* a, const Tensor* b, const Tensor*
: 0u;
MatMulNBitsWideTileProgram program{has_zero_points, has_bias, has_weight_idx, has_weight_idx_indirect,
- tile_m, tile_n, nbits, subgroup_min_size, acc_f32};
+ tile_m, tile_n, nbits, subgroup_min_size, acc_f32, has_qlora_up};
program.SetWorkgroupSize(workgroup_size);
program.SetDispatchGroupSize(num_N_tile, num_M_tile, batch_count);
@@ -216,7 +222,14 @@ Status ApplyWideTileMatMulNBits(const Tensor* a, const Tensor* b, const Tensor*
{num_N_tile},
{num_M_tile},
{weight_index}});
- program.CacheHint(nbits, has_zero_points, has_bias, has_weight_idx, has_weight_idx_indirect, subgroup_min_size, acc_f32);
+ program.CacheHint(nbits, has_zero_points, has_bias, has_weight_idx, has_weight_idx_indirect, subgroup_min_size,
+ acc_f32, has_qlora_up);
+
+ if (has_qlora_up) {
+ program.AddInput({lora_t, ProgramTensorMetadataDependency::Type, 4});
+ program.AddInput({lora_b, ProgramTensorMetadataDependency::Type, 16});
+ program.AddInput({lora_b_scales, ProgramTensorMetadataDependency::Type});
+ }
return context.RunProgram(program);
}
@@ -253,6 +266,14 @@ Status MatMulNBits::ComputeInternal(onnxruntime::webgpu::ComputeContext& context
return Status::OK();
}
+ if (empty_weights) {
+ // Record a fill dispatch so graph replay also overwrites the output. The
+ // MatMul shader uses [batch, row, column] coordinates, even for a vector A.
+ auto program = CreateMatMulFillBiasOrZeroBeforeSplitKProgram(
+ bias, y, false, 1.0f, 1, TensorShape({1, y->Shape().Size() / N_, N_}));
+ return context.RunProgram(program);
+ }
+
return ApplyMatMulNBits(a, b, scales, zero_points, bias, K_, N_, block_size_, accuracy_level_, bits_, context, y, 0);
}
@@ -273,7 +294,8 @@ Status MatMulNBits::ComputeInternal(onnxruntime::webgpu::ComputeContext& context
* @param zero_points Pointer to the zero-point tensor for quantization; must be of type uint8 if provided.
* weight_index > 0 is only supported when zero_points is nullptr.
* @param bias Pointer to the bias tensor; optional.
- * @param K_op The K dimension of the operation (number of columns in 'a' and rows in 'b' before quantization).
+ * @param K_op The K dimension of the operation (number of columns in 'a' and rows in 'b' before
+ * quantization).
* @param N_op The N dimension of the operation (number of columns in 'b').
* @param block_size_op The block size used for quantization partitioning.
* @param accuracy_level Accuracy level influencing the choice of optimized kernel.
@@ -290,17 +312,13 @@ Status MatMulNBits::ComputeInternal(onnxruntime::webgpu::ComputeContext& context
* - A wide tile program is used when block size, component count, and other criteria are met.
* - Otherwise, a default matmul program is used.
*/
-Status ApplyMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales, const Tensor* zero_points, const Tensor* bias,
- int64_t K_op,
- int64_t N_op,
- int64_t block_size_op,
- int64_t accuracy_level,
- int64_t nbits,
- onnxruntime::webgpu::ComputeContext& context,
- Tensor* y,
- const uint32_t weight_index,
- const Tensor* weight_index_indirect,
- uint32_t override_M, DP4AQuantizedInput* quantized_input) {
+Status ApplyMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales, const Tensor* zero_points,
+ const Tensor* bias, int64_t K_op, int64_t N_op, int64_t block_size_op, int64_t accuracy_level,
+ int64_t nbits, onnxruntime::webgpu::ComputeContext& context, Tensor* y,
+ const uint32_t weight_index, const Tensor* weight_index_indirect, uint32_t override_M,
+ DP4AQuantizedInput* quantized_input,
+ const Tensor* lora_t, const Tensor* lora_b, const Tensor* lora_a_transposed,
+ Tensor* lora_a_output, const Tensor* lora_b_scales, const Tensor* lora_a_scales) {
TensorShape b_shape({N_op, K_op});
MatMulComputeHelper helper;
ORT_RETURN_IF_ERROR(helper.Compute(a->Shape(), b_shape, false, true));
@@ -316,12 +334,18 @@ Status ApplyMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales,
return context.RunProgram(program);
}
+ const bool has_qlora_up = lora_t != nullptr;
+ const bool has_qlora_down = lora_a_transposed != nullptr;
+ ORT_RETURN_IF_NOT(!has_qlora_down || (lora_a_output != nullptr && lora_a_scales != nullptr && !has_qlora_up &&
+ a->Shape().Size() == K_op && nbits == 4 && block_size_op == 32),
+ "Unsupported joint QLoRA projection.");
const bool has_bias = bias != nullptr;
const bool has_weight_idx_indirect = weight_index_indirect != nullptr;
const bool has_weight_idx = weight_index > 0 || has_weight_idx_indirect;
const bool has_zero_points = zero_points != nullptr;
if (has_zero_points) {
- ORT_ENFORCE(zero_points->DataType() == DataTypeImpl::GetType(), "Currently, only uint8 is supported for zero points, but got ", zero_points->DataType());
+ ORT_ENFORCE(zero_points->DataType() == DataTypeImpl::GetType(),
+ "Currently, only uint8 is supported for zero points, but got ", zero_points->DataType());
}
const uint32_t batch_count = onnxruntime::narrow(helper.OutputOffsets().size());
@@ -346,58 +370,82 @@ Status ApplyMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales,
// n_blocks_per_col up to the next multiple of (8/nbits) — the number of zero point
// values per byte — so that the linear stride correctly skips byte-boundary padding.
const uint32_t zp_elements_per_byte = 8 / static_cast(nbits);
- uint32_t zero_blocks_per_col = (n_blocks_per_col + zp_elements_per_byte - 1) / zp_elements_per_byte * zp_elements_per_byte;
+ uint32_t zero_blocks_per_col =
+ (n_blocks_per_col + zp_elements_per_byte - 1) / zp_elements_per_byte * zp_elements_per_byte;
// apple|intel - Experimental dawn support for subgroup matrix matmul.
std::optional subgroup_matrix_config;
- if (CanApplySubgroupMatrixMatMulNBits(context,
- accuracy_level,
- block_size,
- batch_count,
- N,
- K,
+ if (!has_qlora_down &&
+ CanApplySubgroupMatrixMatMulNBits(context, accuracy_level, block_size, batch_count, N, K,
static_cast(nbits),
y->DataType() == DataTypeImpl::GetType(),
- subgroup_matrix_config,
- M,
- has_weight_idx_indirect)) {
- return ApplySubgroupMatrixMatMulNBits(a, b, scales, zero_points, bias, M, N, K,
- static_cast(nbits), zero_blocks_per_col,
- *subgroup_matrix_config, context, y, weight_index,
- weight_index_indirect);
+ subgroup_matrix_config, M, has_weight_idx_indirect)) {
+ return ApplySubgroupMatrixMatMulNBits(a, b, scales, zero_points, bias, M, N, K, static_cast(nbits),
+ zero_blocks_per_col, *subgroup_matrix_config, context, y, weight_index,
+ weight_index_indirect, nullptr, lora_t, lora_b, lora_b_scales);
}
- // On FP32 only GPUs and Qualcomm GPUs, integer math is faster than FP32 therefore always use DP4A independent of length of M.
- // DP4A Q2 path now supports custom zero points via a 1024-entry LUT (4 zero-point sections × 256 byte values).
- if (CanApplyDP4AMatrixMatMulNBits(context, accuracy_level, block_size, N, K, components_a,
- M, has_weight_idx_indirect, y)) {
- return ApplyDP4AMatrixMatMulNBits(a, b, scales, zero_points, bias, batch_count, M, dispatch_M, N, K, block_size, zero_blocks_per_col, kMinMForTileOptimization, static_cast(nbits), context, y, weight_index, weight_index_indirect, quantized_input);
+ // On FP32 only GPUs and Qualcomm GPUs, integer math is faster than FP32 therefore always use DP4A independent of
+ // length of M. DP4A Q2 path now supports custom zero points via a 1024-entry LUT (4 zero-point sections × 256 byte
+ // values).
+ if (!has_qlora_down &&
+ CanApplyDP4AMatrixMatMulNBits(context, accuracy_level, block_size, N, K, components_a, M,
+ has_weight_idx_indirect, y)) {
+ return ApplyDP4AMatrixMatMulNBits(a, b, scales, zero_points, bias, batch_count, M, dispatch_M, N, K, block_size,
+ zero_blocks_per_col, kMinMForTileOptimization, static_cast(nbits),
+ context, y, weight_index, weight_index_indirect, quantized_input, lora_t, lora_b,
+ nullptr, nullptr, lora_b_scales);
}
// This program is optimized for FP32/FP16 Block32 prefill.
- if (CanApplyWideTileMatMulNBits(M, K, block_size, nbits, has_weight_idx_indirect,
- components_a, components_b)) {
+ if (!has_qlora_down && CanApplyWideTileMatMulNBits(M, K, block_size, nbits, has_weight_idx_indirect,
+ components_a, components_b)) {
return ApplyWideTileMatMulNBits(a, b, scales, zero_points, bias, batch_count, dispatch_M, N, K,
block_size, zero_blocks_per_col, static_cast(nbits),
- context, y, weight_index, weight_index_indirect);
+ context, y, weight_index, weight_index_indirect, lora_t, lora_b, lora_b_scales);
}
const bool acc_f32 = context.EnableMatmulFp32Accumulation();
// Use tile_size_k_vec=32 by default for better K-dimension parallelism.
// Intel devices use 16 as they have different subgroup/cache characteristics.
- const uint32_t tile_size_k_vec =
- (context.AdapterInfo().vendor == std::string_view{"intel"}) ? 16u : 32u;
+ const uint32_t tile_size_k_vec = (context.AdapterInfo().vendor == std::string_view{"intel"}) ? 16u : 32u;
constexpr uint32_t workgroup_size = 128;
constexpr uint32_t tile_size = 8;
constexpr uint32_t kU32Components = 4;
uint32_t components_b_with_u32 = components_b * kU32Components;
uint32_t K_of_b = (n_blocks_per_col * blob_size) / components_b_with_u32;
- MatMulNBitsProgram program{tile_size, static_cast(nbits), has_zero_points, has_bias, has_weight_idx, has_weight_idx_indirect, single_scale_weights, tile_size_k_vec, broadcast_a, acc_f32};
+ // The decode reduction assigns two ranks to each 32-lane subgroup. Devices
+ // with a variable subgroup range must explicitly request that width.
+ const auto& adapter = context.AdapterInfo();
+ const bool fixed_subgroup_32 = adapter.subgroupMinSize == 32 && adapter.subgroupMaxSize == 32;
+ const bool request_subgroup_32 = has_qlora_down && !fixed_subgroup_32 &&
+ context.HasFeature(wgpu::FeatureName::SubgroupSizeControl) &&
+ adapter.subgroupMinSize <= 32 && adapter.subgroupMaxSize >= 32;
+ const bool qlora_down_subgroups = has_qlora_down && context.HasFeature(wgpu::FeatureName::Subgroups) &&
+ context.HasWGSLLanguageFeature(wgpu::WGSLLanguageFeatureName::SubgroupId) &&
+ (fixed_subgroup_32 || request_subgroup_32);
+ MatMulNBitsProgram program{tile_size,
+ static_cast(nbits),
+ has_zero_points,
+ has_bias,
+ has_weight_idx,
+ has_weight_idx_indirect,
+ single_scale_weights,
+ tile_size_k_vec,
+ broadcast_a,
+ acc_f32,
+ has_qlora_up,
+ has_qlora_down,
+ qlora_down_subgroups};
+ program.SetUseSubgroupId(qlora_down_subgroups);
program.SetWorkgroupSize(workgroup_size);
+ if (qlora_down_subgroups && request_subgroup_32) {
+ program.SetSubgroupSize(32);
+ }
uint32_t num_N_tile = (N + tile_size - 1) / tile_size;
- program.SetDispatchGroupSize(num_N_tile, dispatch_M, batch_count);
+ program.SetDispatchGroupSize(num_N_tile + (has_qlora_down ? 4 : 0), dispatch_M, batch_count);
program
.AddInputs({{a, ProgramTensorMetadataDependency::TypeAndRank, static_cast(components_a)},
{b, ProgramTensorMetadataDependency::TypeAndRank, static_cast(components_b_with_u32)},
@@ -415,7 +463,8 @@ Status ApplyMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales,
{batch_count},
{weight_index},
{dispatch_M}})
- .CacheHint(nbits, has_zero_points, single_scale_weights, has_bias, has_weight_idx, has_weight_idx_indirect, tile_size_k_vec, broadcast_a, acc_f32);
+ .CacheHint(nbits, has_zero_points, single_scale_weights, has_bias, has_weight_idx, has_weight_idx_indirect,
+ tile_size_k_vec, broadcast_a, acc_f32, has_qlora_up, has_qlora_down, qlora_down_subgroups);
if (has_zero_points) {
program.AddInput({zero_points, ProgramTensorMetadataDependency::None, {(zero_points->Shape().Size() + 3) / 4}, 4});
}
@@ -425,6 +474,17 @@ Status ApplyMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales,
if (has_weight_idx_indirect) {
program.AddInput({weight_index_indirect, ProgramTensorMetadataDependency::None});
}
+ if (has_qlora_up) {
+ program.AddInput({lora_t, ProgramTensorMetadataDependency::Type, 4});
+ program.AddInput({lora_b, ProgramTensorMetadataDependency::Type, 16});
+ program.AddInput({lora_b_scales, ProgramTensorMetadataDependency::Type});
+ }
+ if (has_qlora_down) {
+ ORT_RETURN_IF_NOT(components_a == 4, "Joint QLoRA projection requires vec4 X.");
+ program.AddInput({lora_a_transposed, ProgramTensorMetadataDependency::Type, 16});
+ program.AddInput({lora_a_scales, ProgramTensorMetadataDependency::Type});
+ program.AddOutput({lora_a_output, ProgramTensorMetadataDependency::Type});
+ }
return context.RunProgram(program);
}
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits.h b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits.h
index e2205f37920dd..30b75f2b40ad6 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits.h
+++ b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits.h
@@ -16,20 +16,29 @@ struct DP4AQuantizedInput;
class MatMulNBitsWideTileProgram final : public Program {
public:
- MatMulNBitsWideTileProgram(bool has_zero_points, bool has_bias, bool has_weight_idx, bool has_weight_idx_indirect, uint32_t tile_m, uint32_t tile_n, uint32_t nbits, uint32_t subgroup_min_size, bool acc_f32)
- : Program{"MatMulNBitsWideTile"}, has_zero_points_{has_zero_points}, has_bias_{has_bias}, has_weight_idx_{has_weight_idx}, has_weight_idx_indirect_{has_weight_idx_indirect}, tile_m_(tile_m), tile_n_(tile_n), nbits_(nbits), subgroup_min_size_(subgroup_min_size), acc_f32_(acc_f32) {}
+ MatMulNBitsWideTileProgram(bool has_zero_points, bool has_bias, bool has_weight_idx, bool has_weight_idx_indirect,
+ uint32_t tile_m, uint32_t tile_n, uint32_t nbits, uint32_t subgroup_min_size, bool acc_f32,
+ bool has_qlora_up = false)
+ : Program{has_qlora_up ? "MatMulNBitsQLoRAUpWideTile" : "MatMulNBitsWideTile"},
+ has_zero_points_{has_zero_points},
+ has_bias_{has_bias},
+ has_weight_idx_{has_weight_idx},
+ has_weight_idx_indirect_{has_weight_idx_indirect},
+ tile_m_(tile_m),
+ tile_n_(tile_n),
+ nbits_(nbits),
+ subgroup_min_size_(subgroup_min_size),
+ acc_f32_(acc_f32),
+ has_qlora_up_(has_qlora_up) {}
Status GenerateShaderCode(ShaderHelper& sh) const override;
- WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES({"Batch", ProgramUniformVariableDataType::Uint32},
- {"M", ProgramUniformVariableDataType::Uint32},
- {"N", ProgramUniformVariableDataType::Uint32},
- {"K_of_a", ProgramUniformVariableDataType::Uint32},
- {"K_of_b", ProgramUniformVariableDataType::Uint32},
- {"n_blocks_per_col", ProgramUniformVariableDataType::Uint32},
- {"zero_blocks_per_col", ProgramUniformVariableDataType::Uint32},
- {"num_N_tile", ProgramUniformVariableDataType::Uint32},
- {"num_M_tile", ProgramUniformVariableDataType::Uint32},
- {"weight_idx", ProgramUniformVariableDataType::Uint32});
+ WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES(
+ {"Batch", ProgramUniformVariableDataType::Uint32}, {"M", ProgramUniformVariableDataType::Uint32},
+ {"N", ProgramUniformVariableDataType::Uint32}, {"K_of_a", ProgramUniformVariableDataType::Uint32},
+ {"K_of_b", ProgramUniformVariableDataType::Uint32}, {"n_blocks_per_col", ProgramUniformVariableDataType::Uint32},
+ {"zero_blocks_per_col", ProgramUniformVariableDataType::Uint32},
+ {"num_N_tile", ProgramUniformVariableDataType::Uint32}, {"num_M_tile", ProgramUniformVariableDataType::Uint32},
+ {"weight_idx", ProgramUniformVariableDataType::Uint32});
private:
bool has_zero_points_;
@@ -41,26 +50,40 @@ class MatMulNBitsWideTileProgram final : public Program {
public:
- MatMulNBitsProgram(uint32_t tile_size, uint32_t nbits, bool has_zero_points, bool has_bias, bool has_weight_idx, bool has_weight_idx_indirect, bool single_scale_weights, uint32_t tile_size_k_vec = 16, bool broadcast_a_row = false, bool acc_f32 = false)
- : Program{"MatMulNBits"}, tile_size_(tile_size), nbits_(nbits), has_zero_points_(has_zero_points), has_bias_(has_bias), has_weight_idx_{has_weight_idx}, has_weight_idx_indirect_{has_weight_idx_indirect}, single_scale_weights_(single_scale_weights), tile_size_k_vec_(tile_size_k_vec), broadcast_a_row_(broadcast_a_row), acc_f32_(acc_f32) {}
+ MatMulNBitsProgram(uint32_t tile_size, uint32_t nbits, bool has_zero_points, bool has_bias, bool has_weight_idx,
+ bool has_weight_idx_indirect, bool single_scale_weights, uint32_t tile_size_k_vec = 16,
+ bool broadcast_a_row = false, bool acc_f32 = false, bool has_qlora_up = false,
+ bool has_qlora_down = false, bool qlora_down_subgroups = false)
+ : Program{has_qlora_down ? (qlora_down_subgroups ? "MatMulNBitsQLoRADownSubgroup" : "MatMulNBitsQLoRADown")
+ : has_qlora_up ? "MatMulNBitsQLoRAUp"
+ : "MatMulNBits"},
+ tile_size_(tile_size),
+ nbits_(nbits),
+ has_zero_points_(has_zero_points),
+ has_bias_(has_bias),
+ has_weight_idx_{has_weight_idx},
+ has_weight_idx_indirect_{has_weight_idx_indirect},
+ single_scale_weights_(single_scale_weights),
+ tile_size_k_vec_(tile_size_k_vec),
+ broadcast_a_row_(broadcast_a_row),
+ acc_f32_(acc_f32),
+ has_qlora_up_(has_qlora_up),
+ has_qlora_down_(has_qlora_down),
+ qlora_down_subgroups_(qlora_down_subgroups) {}
Status GenerateShaderCode(ShaderHelper& sh) const override;
WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES(
- {"M", ProgramUniformVariableDataType::Uint32},
- {"N", ProgramUniformVariableDataType::Uint32},
- {"K", ProgramUniformVariableDataType::Uint32},
- {"K_of_a", ProgramUniformVariableDataType::Uint32},
- {"K_of_b", ProgramUniformVariableDataType::Uint32},
- {"block_size", ProgramUniformVariableDataType::Uint32},
+ {"M", ProgramUniformVariableDataType::Uint32}, {"N", ProgramUniformVariableDataType::Uint32},
+ {"K", ProgramUniformVariableDataType::Uint32}, {"K_of_a", ProgramUniformVariableDataType::Uint32},
+ {"K_of_b", ProgramUniformVariableDataType::Uint32}, {"block_size", ProgramUniformVariableDataType::Uint32},
{"blocks_per_col", ProgramUniformVariableDataType::Uint32},
{"zero_blocks_per_col", ProgramUniformVariableDataType::Uint32},
- {"num_N_tile", ProgramUniformVariableDataType::Uint32},
- {"batch_count", ProgramUniformVariableDataType::Uint32},
- {"weight_idx", ProgramUniformVariableDataType::Uint32},
- {"dispatch_M", ProgramUniformVariableDataType::Uint32});
+ {"num_N_tile", ProgramUniformVariableDataType::Uint32}, {"batch_count", ProgramUniformVariableDataType::Uint32},
+ {"weight_idx", ProgramUniformVariableDataType::Uint32}, {"dispatch_M", ProgramUniformVariableDataType::Uint32});
private:
uint32_t tile_size_;
@@ -73,6 +96,9 @@ class MatMulNBitsProgram final : public Program {
uint32_t tile_size_k_vec_;
bool broadcast_a_row_;
bool acc_f32_;
+ bool has_qlora_up_;
+ bool has_qlora_down_;
+ bool qlora_down_subgroups_;
};
class MatMulNBits final : public WebGpuKernel {
@@ -105,12 +131,14 @@ bool CanApplyWideTileMatMulNBits(uint32_t M,
uint32_t components_a = 0,
uint32_t components_b = 0);
-Status ApplyMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales, const Tensor* zero_points, const Tensor* bias,
- int64_t K_op, int64_t N_op, int64_t block_size_op, int64_t accuracy_level, int64_t bits_op,
- onnxruntime::webgpu::ComputeContext& context, Tensor* y, const uint32_t weight_index = 0,
- const Tensor* weight_index_indirect = nullptr,
- uint32_t override_M = 0,
- DP4AQuantizedInput* quantized_input = nullptr);
+Status ApplyMatMulNBits(const Tensor* a, const Tensor* b, const Tensor* scales, const Tensor* zero_points,
+ const Tensor* bias, int64_t K_op, int64_t N_op, int64_t block_size_op, int64_t accuracy_level,
+ int64_t bits_op, onnxruntime::webgpu::ComputeContext& context, Tensor* y,
+ const uint32_t weight_index = 0, const Tensor* weight_index_indirect = nullptr,
+ uint32_t override_M = 0, DP4AQuantizedInput* quantized_input = nullptr,
+ const Tensor* lora_t = nullptr, const Tensor* lora_b = nullptr,
+ const Tensor* lora_a_transposed = nullptr, Tensor* lora_a_output = nullptr,
+ const Tensor* lora_b_scales = nullptr, const Tensor* lora_a_scales = nullptr);
} // namespace webgpu
} // namespace contrib
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits.wgsl.template b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits.wgsl.template
index b75bbbebcddc2..69e320d1acde5 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits.wgsl.template
+++ b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits.wgsl.template
@@ -13,6 +13,7 @@
#param tile_size_k_vec
#param tile_size_k
#param tile_size
+#param has_qlora_up
#param has_bias
#param has_weight_idx
#param has_weight_idx_indirect
@@ -264,7 +265,12 @@ $MAIN {
output_value += acc_element_t(bias[b_global + b_bias_offset]);
#endif
// Downcast to the output type only at the final write.
+#if has_qlora_up
+ let delta = qlora_delta(batch * uniforms.M + a_global, b_global);
+ output.setByOffset(output_idx, output_element_t(output_value) + delta);
+#else
output.setByOffset(output_idx, output_element_t(output_value));
+#endif
}
}
} // MAIN
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora.cc b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora.cc
new file mode 100644
index 0000000000000..acc6f6342e381
--- /dev/null
+++ b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora.cc
@@ -0,0 +1,206 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#include "contrib_ops/webgpu/quantization/matmul_nbits_qlora.h"
+#include "contrib_ops/webgpu/quantization/dp4a_matmul_nbits.h"
+#include "contrib_ops/webgpu/quantization/matmul_nbits.h"
+#include "contrib_ops/webgpu/quantization/matmul_nbits_common.h"
+#include "contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.h"
+#include "contrib_ops/webgpu/webgpu_contrib_kernels.h"
+#include "core/common/safeint.h"
+#include "core/providers/cpu/math/matmul_helper.h"
+#include "core/providers/webgpu/shader_helper.h"
+#include "core/providers/webgpu/webgpu_supported_types.h"
+
+namespace onnxruntime::contrib::webgpu {
+
+Status GenerateQLoRADown(ShaderHelper& shader, const ShaderVariableHelper& x, bool acc_f32,
+ bool use_subgroups) {
+ return WGSL_TEMPLATE_APPLY(shader, "quantization/matmul_nbits_qlora_down.wgsl.template",
+ WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32),
+ WGSL_TEMPLATE_PARAMETER(use_subgroups, use_subgroups),
+ WGSL_TEMPLATE_VARIABLE(x, x));
+}
+
+Status GenerateQLoRAUp(ShaderHelper& shader, bool acc_f32, bool vector_output) {
+ return WGSL_TEMPLATE_APPLY(shader, "quantization/matmul_nbits_qlora_up.wgsl.template",
+ WGSL_TEMPLATE_PARAMETER(acc_f32, acc_f32),
+ WGSL_TEMPLATE_PARAMETER(vector_output, vector_output));
+}
+
+namespace {
+
+// Single-row execution stores the base projection separately. This shader computes
+// the rank-32 up projection and adds it to that base output in one dispatch.
+class QLoRAOutputProgram final : public Program {
+ public:
+ explicit QLoRAOutputProgram(bool acc_f32) : Program{"QLoRAOutputVec4"}, acc_f32_(acc_f32) {}
+ ORT_DISALLOW_COPY_ASSIGNMENT_AND_MOVE(QLoRAOutputProgram);
+ WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES({"size", ProgramUniformVariableDataType::Uint32},
+ {"N", ProgramUniformVariableDataType::Uint32},
+ {"M", ProgramUniformVariableDataType::Uint32});
+ Status GenerateShaderCode(ShaderHelper& shader) const override {
+ shader.AddInput("base");
+ shader.AddInput("lora_t");
+ shader.AddInput("lora_b");
+ shader.AddInput("lora_b_scales");
+ shader.AddOutput("output", ShaderUsage::UseElementTypeAlias);
+ ORT_RETURN_IF_ERROR(GenerateQLoRAUp(shader, acc_f32_));
+ shader.MainFunctionBody()
+ << "if (global_idx >= uniforms.size) { return; }\n"
+ << "output[global_idx] = base[global_idx] + qlora_delta(global_idx / uniforms.N, global_idx % uniforms.N);\n";
+ return Status::OK();
+ }
+
+ private:
+ bool acc_f32_;
+};
+
+} // namespace
+
+Status ApplyQLoRAOutput(onnxruntime::webgpu::ComputeContext& context, const Tensor* base, const Tensor* t,
+ const Tensor* b, const Tensor* scales, Tensor* y) {
+ const auto size = narrow(y->Shape().Size());
+ QLoRAOutputProgram program(context.EnableMatmulFp32Accumulation());
+ program
+ .AddInputs({{base, ProgramTensorMetadataDependency::Type, ProgramInput::Flatten},
+ {t, ProgramTensorMetadataDependency::Type, 4},
+ {b, ProgramTensorMetadataDependency::Type, 16},
+ {scales, ProgramTensorMetadataDependency::Type}})
+ .AddOutput({y, ProgramTensorMetadataDependency::Type, ProgramOutput::Flatten})
+ .AddUniformVariables(
+ {{size}, {narrow(b->Shape()[0])}, {narrow(y->Shape()[y->Shape().NumDimensions() - 2])}})
+ .SetWorkgroupSize(64)
+ .SetDispatchGroupSize(CeilDiv(size, 64u))
+ .CacheHint(context.EnableMatmulFp32Accumulation());
+ return context.RunProgram(program);
+}
+
+class MatMulNBitsQLoRA final : public WebGpuKernel {
+ public:
+ ORT_DISALLOW_COPY_ASSIGNMENT_AND_MOVE(MatMulNBitsQLoRA);
+ explicit MatMulNBitsQLoRA(const OpKernelInfo& info)
+ : WebGpuKernel(info),
+ k_(info.GetAttr("K")),
+ n_(info.GetAttr("N")),
+ accuracy_level_(info.GetAttrOrDefault("accuracy_level", 0)) {
+ ORT_ENFORCE(info.GetAttr("bits") == 4 &&
+ info.GetAttr("block_size") == 32 && info.GetAttr("lora_bits") == 8 &&
+ info.GetAttr("lora_block_size") == 32,
+ "WebGPU MatMulNBitsQLoRA supports only bits=4, block_size=32, lora_bits=8, and lora_block_size=32.");
+ ORT_ENFORCE(k_ > 0 && k_ % 32 == 0 && n_ > 0,
+ "WebGPU MatMulNBitsQLoRA requires positive K divisible by 32 and positive N.");
+ }
+
+ Status ComputeInternal(onnxruntime::webgpu::ComputeContext& context) const override {
+ const auto* x = context.Input(0);
+ const auto* w = context.Input(1);
+ const auto* s = context.Input(2);
+ const auto* aq = context.Input(3);
+ const auto* as = context.Input(4);
+ const auto* bq = context.Input(5);
+ const auto* bs = context.Input(6);
+ ORT_RETURN_IF_NOT(x->Shape().NumDimensions() >= 2, "WebGPU MatMulNBitsQLoRA requires X rank at least two.");
+ ORT_RETURN_IF_NOT(aq->Shape().NumDimensions() == 3 && bq->Shape().NumDimensions() == 3,
+ "Invalid quantized LoRA input ranks.");
+ ORT_RETURN_IF_NOT(
+ w->Shape() == TensorShape({n_, k_ / 32, 16}) && s->Shape().Size() == SafeInt(n_) * (k_ / 32),
+ "Invalid base weight/scales shape.");
+ // TODO: Support arbitrary adapter ranks with a rank uniform, per-block up scales,
+ // and vector-tail handling while preserving rank-32 performance.
+ const auto rank_a = aq->Shape()[0];
+ const auto blocks_b = bq->Shape()[1];
+ ORT_RETURN_IF_NOT((rank_a == 0 || rank_a == 32) && (blocks_b == 0 || blocks_b == 1),
+ "WebGPU MatMulNBitsQLoRA supports only adapter rank 32 (or empty adapters).");
+ ORT_RETURN_IF_NOT(aq->Shape()[1] == k_ / 32 &&
+ aq->Shape()[2] == 32 && bq->Shape()[0] == n_ && bq->Shape()[2] == 32,
+ "Invalid packed adapter dimensions.");
+ MatMulComputeHelper helper;
+ ORT_RETURN_IF_ERROR(helper.Compute(x->Shape(), TensorShape({n_, k_}), false, true));
+ auto* y = context.Output(0, helper.OutputShape());
+ if (y->Shape().Size() == 0) return Status::OK();
+ if (rank_a == 0 || blocks_b == 0) {
+ return ApplyMatMulNBits(x, w, s, nullptr, nullptr, k_, n_, 32, accuracy_level_, 4, context, y);
+ }
+ ORT_RETURN_IF_NOT(as->Shape() == TensorShape({32, k_ / 32}) && bs->Shape() == TensorShape({n_, 1}),
+ "Invalid adapter scales shape.");
+ // With logical dequantized weights, Y = X * W + T * B_up, where T = X * A_down.
+ // T keeps X's leading dimensions and replaces K with the adapter rank (32).
+ auto t_shape = x->Shape().AsShapeVector();
+ t_shape.back() = 32;
+ auto t = context.CreateGPUTensor(x->DataType(), TensorShape(t_shape));
+ const auto m = narrow(x->Shape()[x->Shape().NumDimensions() - 2]);
+ // Prefill fuses T * B_up into the base shader's output stage. Single-row
+ // decode uses ApplyQLoRAOutput after computing the base projection and T.
+ const bool fuse_up = m > 1;
+ // FP32 may select DP4A even for M=1. The joint floating-point down
+ // shader must not replace that base projection's activation quantization.
+ const bool base_dp4a = CanApplyDP4AMatrixMatMulNBits(context, accuracy_level_, 32, narrow(n_),
+ narrow(k_), 4, m, false, y);
+ std::optional base_subgroup_matrix_config;
+ const auto subgroup_m = narrow(helper.M());
+ const bool base_subgroup_matrix =
+ fuse_up && CanApplySubgroupMatrixMatMulNBits(
+ context, accuracy_level_, 32, narrow(helper.OutputOffsets().size()),
+ narrow(n_), narrow(k_), 4, x->IsDataType(),
+ base_subgroup_matrix_config, subgroup_m, false);
+ if (base_subgroup_matrix && base_subgroup_matrix_config->Is(8, 16, 16)) {
+ const auto down_config = SelectSubgroupMatrixConfig(
+ context, {{wgpu::SubgroupMatrixComponentType::F16, wgpu::SubgroupMatrixComponentType::F32,
+ 8, 16, 16, 32, true}});
+ if (down_config.has_value()) {
+ return ApplySubgroupMatrixMatMulNBitsQLoRA(
+ x, w, s, aq, as, bq, bs, subgroup_m, narrow(n_), narrow(k_),
+ *base_subgroup_matrix_config, *down_config, context, &t, y);
+ }
+ }
+ if (!fuse_up && m == 1 && x->IsDataType() && base_dp4a && k_ >= 2048 && k_ <= 8192) {
+ auto base = context.CreateGPUTensor(y->DataType(), y->Shape());
+ ORT_RETURN_IF_ERROR(ApplyDP4AMatrixMatMulNBits(
+ x, w, s, nullptr, nullptr, narrow(x->Shape().Size() / k_), 1, 1, narrow(n_),
+ narrow(k_), 32, narrow(k_ / 32), kMinMForTileOptimization, 4, context, &base, 0, nullptr,
+ nullptr, nullptr, nullptr, aq, &t, nullptr, as));
+ return ApplyQLoRAOutput(context, &base, &t, bq, bs, y);
+ }
+ // For one input vector, append four rank-reduction workgroups to the generic
+ // base dispatch. GenerateQLoRADown supplies their shader body; DP4A has its own.
+ const bool joint_down = !fuse_up && x->Shape().Size() == k_ && k_ >= 2048 && k_ <= 8192 && !base_dp4a;
+ if (joint_down) {
+ auto base = context.CreateGPUTensor(y->DataType(), y->Shape());
+ ORT_RETURN_IF_ERROR(ApplyMatMulNBits(x, w, s, nullptr, nullptr, k_, n_, 32, accuracy_level_, 4, context, &base, 0,
+ nullptr, 0, nullptr, nullptr, nullptr, aq, &t, nullptr, as));
+ return ApplyQLoRAOutput(context, &base, &t, bq, bs, y);
+ }
+ // FP32 small-M prefill can use the existing DP4A down kernel and share
+ // quantized X with the base projection when accuracy_level permits it.
+ const auto down_accuracy =
+ fuse_up &&
+ (m >= kMinMForTileOptimization || (x->IsDataType() && base_dp4a))
+ ? accuracy_level_
+ : 0;
+ DP4AQuantizedInput quantized_x;
+ ORT_RETURN_IF_ERROR(ApplyMatMulNBits(x, aq, as, nullptr, nullptr, k_, 32, 32, down_accuracy, 8, context, &t, 0,
+ nullptr, 0, &quantized_x));
+ if (fuse_up) {
+ return ApplyMatMulNBits(x, w, s, nullptr, nullptr, k_, n_, 32, accuracy_level_, 4, context, y, 0, nullptr, 0,
+ &quantized_x, &t, bq, nullptr, nullptr, bs);
+ }
+ auto base = context.CreateGPUTensor(y->DataType(), y->Shape());
+ ORT_RETURN_IF_ERROR(ApplyMatMulNBits(x, w, s, nullptr, nullptr, k_, n_, 32, accuracy_level_, 4, context, &base, 0,
+ nullptr, 0, &quantized_x));
+ return ApplyQLoRAOutput(context, &base, &t, bq, bs, y);
+ }
+
+ private:
+ int64_t k_;
+ int64_t n_;
+ int64_t accuracy_level_;
+};
+
+ONNX_OPERATOR_KERNEL_EX(MatMulNBitsQLoRA, kMSDomain, 1, kWebGpuExecutionProvider,
+ (*KernelDefBuilder::Create())
+ .TypeConstraint("T", WebGpuSupportedFloatTypes())
+ .TypeConstraint("TQ", DataTypeImpl::GetTensorType()),
+ MatMulNBitsQLoRA);
+
+} // namespace onnxruntime::contrib::webgpu
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora.h b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora.h
new file mode 100644
index 0000000000000..667b37c3b52b1
--- /dev/null
+++ b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora.h
@@ -0,0 +1,34 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#pragma once
+
+#include "core/providers/webgpu/program.h"
+
+namespace onnxruntime::webgpu {
+class ShaderVariableHelper;
+}
+
+namespace onnxruntime::contrib::webgpu {
+
+// These C++ generation helpers append reusable WGSL fragments to the caller's
+// shader. They do not create standalone GPU programs or dispatch GPU work.
+//
+// Append the rank-32 down-projection body to the generic single-vector decode
+// shader. The base projection and T = X * A_down run in the same dispatch using
+// separate workgroups. X uses vec4 loads; uniforms.num_N_tile marks the four
+// appended adapter workgroups.
+Status GenerateQLoRADown(onnxruntime::webgpu::ShaderHelper& shader, const onnxruntime::webgpu::ShaderVariableHelper& x,
+ bool acc_f32, bool use_subgroups = false);
+
+// Append WGSL helper functions for up-weight dequantization and delta = T * B_up.
+// Generic and DP4A fused prefill shaders and the separate decode output shader
+// reuse these functions. Subgroup-matrix shaders reuse only the dequantization
+// helper and implement the multiplication with their own matrix instructions.
+Status GenerateQLoRAUp(onnxruntime::webgpu::ShaderHelper& shader, bool acc_f32, bool vector_output = false);
+
+// Dispatch the decode output shader to compute Y = base + T * B_up.
+Status ApplyQLoRAOutput(onnxruntime::webgpu::ComputeContext& context, const Tensor* base, const Tensor* t,
+ const Tensor* b, const Tensor* scales, Tensor* y);
+
+} // namespace onnxruntime::contrib::webgpu
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora_down.wgsl.template b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora_down.wgsl.template
new file mode 100644
index 0000000000000..f1854a3e2495f
--- /dev/null
+++ b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora_down.wgsl.template
@@ -0,0 +1,119 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#param acc_f32
+#param use_subgroups
+
+#use .getByOffset
+
+// Joint single-vector decode: the base projection and T = X * A_down share
+// one dispatch. lora_at stores rank-major packed INT8 weights; lora_down is
+// the rank-32 intermediate T consumed by the later up projection.
+#if acc_f32
+alias qlora_acc_t = f32;
+#else
+alias qlora_acc_t = output_element_t;
+#endif
+
+#if !use_subgroups
+var qlora_x_tile: array, 128>;
+var qlora_partials: array, 8>;
+#endif
+
+$MAIN {
+ // Four appended workgroups compute eight ranks each. Normalizing the dispatch
+ // to multiple dimensions may add padding workgroups, which must not write T.
+ let qlora_base_groups = uniforms.num_N_tile;
+ if (workgroup_idx >= qlora_base_groups + 4u) { return; }
+ if (workgroup_idx >= qlora_base_groups) {
+ let rank_base = (workgroup_idx - qlora_base_groups) * 8u;
+#if use_subgroups
+ // Each of the four 32-lane subgroups computes two ranks. Subgroup membership
+ // need not match consecutive local invocation indices, so use subgroup IDs.
+ let lane = sg_id;
+ let rank_local = subgroup_index;
+ var partial0 = qlora_acc_t(0);
+ var partial1 = qlora_acc_t(0);
+ for (var k = lane * 16u; k < uniforms.K; k += 512u) {
+ var k4 = k / 4u;
+ // Unrolling keeps the four X vectors in registers for reuse by both ranks
+ // and fixes the dot-product grouping that determines FP16 rounding.
+ let x0 = vec4(x.getByOffset(k4));
+ k4 += 1u;
+ let x1 = vec4(x.getByOffset(k4));
+ k4 += 1u;
+ let x2 = vec4(x.getByOffset(k4));
+ k4 += 1u;
+ let x3 = vec4(x.getByOffset(k4));
+ let rank0 = rank_base + rank_local + 0u;
+ let packed0 = lora_at[rank0 * (uniforms.K / 16u) + k / 16u];
+ let scale0 = lora_a_scales[rank0 * (uniforms.K / 32u) + k / 32u];
+ var sum0 = qlora_acc_t(0);
+ sum0 += dot(x0, vec4(
+ (vec4(unpack4xU8(packed0[0])) - vec4(128)) * scale0));
+ sum0 += dot(x1, vec4(
+ (vec4(unpack4xU8(packed0[1])) - vec4(128)) * scale0));
+ sum0 += dot(x2, vec4(
+ (vec4(unpack4xU8(packed0[2])) - vec4(128)) * scale0));
+ sum0 += dot(x3, vec4(
+ (vec4(unpack4xU8(packed0[3])) - vec4(128)) * scale0));
+ partial0 += sum0;
+ let rank1 = rank_base + rank_local + 4u;
+ let packed1 = lora_at[rank1 * (uniforms.K / 16u) + k / 16u];
+ let scale1 = lora_a_scales[rank1 * (uniforms.K / 32u) + k / 32u];
+ var sum1 = qlora_acc_t(0);
+ sum1 += dot(x0, vec4(
+ (vec4(unpack4xU8(packed1[0])) - vec4(128)) * scale1));
+ sum1 += dot(x1, vec4(
+ (vec4(unpack4xU8(packed1[1])) - vec4(128)) * scale1));
+ sum1 += dot(x2, vec4(
+ (vec4(unpack4xU8(packed1[2])) - vec4(128)) * scale1));
+ sum1 += dot(x3, vec4(
+ (vec4(unpack4xU8(packed1[3])) - vec4(128)) * scale1));
+ partial1 += sum1;
+ }
+ // FP32 avoids half-precision rounding at each step of the subgroup tree.
+ let result0 = subgroupAdd(f32(partial0));
+ let result1 = subgroupAdd(f32(partial1));
+ if (lane == 0u) {
+ lora_down[rank_base + rank_local] = output_element_t(result0);
+ lora_down[rank_base + rank_local + 4u] = output_element_t(result1);
+ }
+#else
+ let lane = local_idx % 32u;
+ let rank_local = local_idx / 32u;
+ // Match the generic INT8 MatMulNBits tile, dot grouping and reduction order
+ // so the FP16 rounding behavior is preserved in this shared-memory fallback.
+ for (var start = 0u; start < uniforms.K; start += 512u) {
+ let k4 = start / 4u + local_idx;
+ var xv = vec4(0);
+ if (k4 < uniforms.K / 4u) { xv = x.getByOffset(k4); }
+ qlora_x_tile[local_idx] = xv;
+ workgroupBarrier();
+ for (var rank_offset = 0u; rank_offset < 8u; rank_offset += 4u) {
+ let rank = rank_base + rank_offset + rank_local;
+ let k = start + lane * 16u;
+ if (k < uniforms.K) {
+ let packed = lora_at[rank * (uniforms.K / 16u) + k / 16u];
+ let scale = lora_a_scales[rank * (uniforms.K / 32u) + k / 32u];
+ var sum = qlora_acc_t(0);
+ for (var i = 0u; i < 4u; i++) {
+ let bv = (vec4(unpack4xU8(packed[i])) - vec4(128)) * scale;
+ sum += dot(vec4(qlora_x_tile[lane * 4u + i]), vec4(bv));
+ }
+ qlora_partials[rank_offset + rank_local][lane] += sum;
+ }
+ }
+ workgroupBarrier();
+ }
+ if (local_idx < 8u) {
+ var value = qlora_acc_t(0);
+ for (var i = 0u; i < 32u; i++) { value += qlora_partials[local_idx][i]; }
+ lora_down[rank_base + local_idx] = output_element_t(value);
+ }
+#endif
+ // Adapter workgroups stop here; the remaining workgroups execute the base
+ // MatMulNBits body appended after this template.
+ return;
+ }
+}
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora_up.wgsl.template b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora_up.wgsl.template
new file mode 100644
index 0000000000000..e44cd7957490f
--- /dev/null
+++ b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_qlora_up.wgsl.template
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#param acc_f32
+#param vector_output
+
+// T = X * A_down is stored in lora_t as eight vec4 values per row (rank 32).
+// These helpers compute the adapter contribution delta = T * B_up. The caller
+// adds delta to the base projection, either in its output stage or a separate
+// decode dispatch. Subgroup-matrix templates reuse only qlora_weight and perform
+// the up multiplication with their own matrix instructions.
+#if acc_f32
+alias qlora_acc_t = f32;
+#else
+alias qlora_acc_t = output_element_t;
+#endif
+
+fn qlora_weight(col: u32, r4: u32) -> vec4 {
+ // Each output column has 32 packed INT8 weights in two vec4 values.
+ // r4 selects four consecutive ranks; 128 is the implicit zero point.
+ let packed = lora_b[col * 2u + r4 / 4u][r4 % 4u];
+ return (vec4(unpack4xU8(packed)) - vec4(128)) * lora_b_scales[col];
+}
+
+fn qlora_delta(row: u32, col: u32) -> output_element_t {
+ var delta = qlora_acc_t(0);
+ // Match the unfused INT8 up projection's reduction order: wide tiles at M>=4,
+ // generic tiles otherwise. Regrouping these sums can change FP16 rounding.
+ if (uniforms.M >= 4u) {
+ for (var r4 = 0u; r4 < 8u; r4 += 2u) {
+ let a0 = lora_t[row * 8u + r4];
+ let a1 = lora_t[row * 8u + r4 + 1u];
+ delta += dot(vec4(a0), vec4(qlora_weight(col, r4))) +
+ dot(vec4(a1), vec4(qlora_weight(col, r4 + 1u)));
+ }
+ } else {
+ for (var block = 0u; block < 2u; block++) {
+ var sum = qlora_acc_t(0);
+ for (var i = 0u; i < 4u; i++) {
+ let r4 = block * 4u + i;
+ sum += dot(vec4(lora_t[row * 8u + r4]), vec4(qlora_weight(col, r4)));
+ }
+ delta += sum;
+ }
+ // The remaining generic-kernel partials are zero, including their sign.
+ delta += qlora_acc_t(0);
+ }
+ return output_element_t(delta);
+}
+
+#if vector_output
+// Tiled DP4A writes four adjacent output columns at once. Reuse each T vector
+// across those columns while keeping the scalar wide-tile dot grouping.
+fn qlora_delta4(row: u32, col: u32) -> vec4 {
+ var delta = vec4(0);
+ for (var r4 = 0u; r4 < 8u; r4 += 2u) {
+ let a0 = vec4(lora_t[row * 8u + r4]);
+ let a1 = vec4(lora_t[row * 8u + r4 + 1u]);
+ delta += vec4(
+ dot(a0, vec4(qlora_weight(col, r4))),
+ dot(a0, vec4(qlora_weight(col + 1u, r4))),
+ dot(a0, vec4(qlora_weight(col + 2u, r4))),
+ dot(a0, vec4(qlora_weight(col + 3u, r4)))) +
+ vec4(
+ dot(a1, vec4(qlora_weight(col, r4 + 1u))),
+ dot(a1, vec4(qlora_weight(col + 1u, r4 + 1u))),
+ dot(a1, vec4(qlora_weight(col + 2u, r4 + 1u))),
+ dot(a1, vec4(qlora_weight(col + 3u, r4 + 1u))));
+ }
+ return vec4(delta);
+}
+#endif
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_wide_tile.wgsl.template b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_wide_tile.wgsl.template
index a433f8064f5e5..bc24ccccdac33 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_wide_tile.wgsl.template
+++ b/onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits_wide_tile.wgsl.template
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
#param has_zero_points
+#param has_qlora_up
#param has_bias
#param has_weight_idx
#param has_weight_idx_indirect
@@ -113,7 +114,12 @@ fn load_scale(row : u32, block_idx : u32) -> output_element_t {
fn write_output(batch : u32, row : u32, col : u32, value : output_element_t) {
if (batch < uniforms.Batch && row < uniforms.M && col < uniforms.N) {
let offset = batch * uniforms.M * uniforms.N + row * uniforms.N + col;
+#if has_qlora_up
+ let delta = qlora_delta(batch * uniforms.M + row, col);
+ output.setByOffset(offset, value + delta);
+#else
output.setByOffset(offset, value);
+#endif
}
}
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.cc b/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.cc
index 2396dbefd7fa8..7c0f867221459 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.cc
+++ b/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.cc
@@ -2,7 +2,10 @@
// Licensed under the MIT License.
#include "contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.h"
+
+#include
#include "contrib_ops/webgpu/quantization/matmul_nbits_common.h"
+#include "contrib_ops/webgpu/quantization/matmul_nbits_qlora.h"
#include "core/providers/webgpu/math/subgroup_matrix_config.h"
#include "core/providers/webgpu/webgpu_utils.h"
@@ -63,10 +66,11 @@ Status GenerateShaderCode16x16x16(ShaderHelper& shader,
const ShaderVariableHelper& output,
uint32_t nbits, const SubgroupMatrixConfig& config, uint32_t tile_size_m,
uint32_t tile_size_n, uint32_t tile_size_k, bool has_zero_points, bool has_bias,
- bool has_weight_idx, bool has_weight_idx_indirect) {
+ bool has_weight_idx, bool has_weight_idx_indirect, bool has_qlora_up) {
// Use the configurable tiled shader for the 16x16x16 configs.
return WGSL_TEMPLATE_APPLY(shader, "quantization/subgroup_matrix_matmul_nbits_16x16x16_128.wgsl.template",
WGSL_TEMPLATE_PARAMETER(has_bias, has_bias),
+ WGSL_TEMPLATE_PARAMETER(has_qlora_up, has_qlora_up),
WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx),
WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect),
WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points),
@@ -87,10 +91,13 @@ Status GenerateShaderCode8x16x16(ShaderHelper& shader,
const ShaderVariableHelper& b,
const ShaderVariableHelper& scales_b,
const ShaderVariableHelper& output,
- uint32_t nbits, const SubgroupMatrixConfig& config, bool has_zero_points, bool has_bias, bool has_weight_idx, bool has_weight_idx_indirect,
+ uint32_t nbits, const SubgroupMatrixConfig& config, bool has_zero_points, bool has_bias,
+ bool has_weight_idx, bool has_weight_idx_indirect, bool has_qlora_up,
uint32_t tile_m, uint32_t tile_n, bool has_tail_buffer) {
return WGSL_TEMPLATE_APPLY(shader, "quantization/subgroup_matrix_matmul_nbits_8x16x16.wgsl.template",
+ WGSL_TEMPLATE_PARAMETER(acc_f32, config.resultComponentType == wgpu::SubgroupMatrixComponentType::F32),
WGSL_TEMPLATE_PARAMETER(has_bias, has_bias),
+ WGSL_TEMPLATE_PARAMETER(has_qlora_up, has_qlora_up),
WGSL_TEMPLATE_PARAMETER(has_tail_buffer, has_tail_buffer),
WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx),
WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect),
@@ -109,9 +116,11 @@ Status GenerateShaderCode8x16x16(ShaderHelper& shader,
Status GenerateShaderCode8x8x8(ShaderHelper& shader, const ShaderVariableHelper& a, const ShaderVariableHelper& b,
const ShaderVariableHelper& scales_b,
- const ShaderVariableHelper& output, uint32_t nbits, bool has_zero_points, bool has_bias, bool has_weight_idx, bool has_weight_idx_indirect) {
+ const ShaderVariableHelper& output, uint32_t nbits, bool has_zero_points, bool has_bias,
+ bool has_weight_idx, bool has_weight_idx_indirect, bool has_qlora_up) {
return WGSL_TEMPLATE_APPLY(shader, "quantization/subgroup_matrix_matmul_nbits_8x8x8.wgsl.template",
WGSL_TEMPLATE_PARAMETER(has_bias, has_bias),
+ WGSL_TEMPLATE_PARAMETER(has_qlora_up, has_qlora_up),
WGSL_TEMPLATE_PARAMETER(has_weight_idx, has_weight_idx),
WGSL_TEMPLATE_PARAMETER(has_weight_idx_indirect, has_weight_idx_indirect),
WGSL_TEMPLATE_PARAMETER(has_zero_points, has_zero_points),
@@ -136,20 +145,29 @@ Status SubgroupMatrixMatMulNBitsProgram::GenerateShaderCode(ShaderHelper& shader
if (has_weight_idx_indirect_) {
shader.AddInput("weight_index_indirect", ShaderUsage::UseUniform);
}
+ if (has_qlora_up_) {
+ shader.AddInput("lora_t", ShaderUsage::UseElementTypeAlias);
+ shader.AddInput("lora_b", ShaderUsage::UseElementTypeAlias);
+ shader.AddInput("lora_b_scales");
+ ORT_RETURN_IF_ERROR(GenerateQLoRAUp(shader, false));
+ }
const auto& output = shader.AddOutput("output", ShaderUsage::UseUniform | ShaderUsage::UseElementTypeAlias);
if (has_tail_buffer_) {
shader.AddOutput("tail_output", ShaderUsage::None);
}
if (config_.Is(8, 8, 8)) {
- return GenerateShaderCode8x8x8(shader, a, b, scales_b, output, nbits_, has_zero_points_, has_bias_, has_weight_idx_, has_weight_idx_indirect_);
+ return GenerateShaderCode8x8x8(shader, a, b, scales_b, output, nbits_, has_zero_points_, has_bias_,
+ has_weight_idx_, has_weight_idx_indirect_, has_qlora_up_);
} else if (config_.Is(8, 16, 16)) {
- return GenerateShaderCode8x16x16(shader, b, scales_b, output, nbits_, config_, has_zero_points_, has_bias_, has_weight_idx_, has_weight_idx_indirect_, tile_m_, tile_n_, has_tail_buffer_);
+ return GenerateShaderCode8x16x16(shader, b, scales_b, output, nbits_, config_, has_zero_points_, has_bias_,
+ has_weight_idx_, has_weight_idx_indirect_, has_qlora_up_, tile_m_, tile_n_,
+ has_tail_buffer_);
} else if (config_.Is(16, 16, 16)) {
return GenerateShaderCode16x16x16(shader, b, scales_b, output, nbits_, config_, tile_m_,
tile_n_, tile_k_,
has_zero_points_, has_bias_, has_weight_idx_,
- has_weight_idx_indirect_);
+ has_weight_idx_indirect_, has_qlora_up_);
} else {
return Status(onnxruntime::common::ONNXRUNTIME, onnxruntime::common::NOT_IMPLEMENTED,
"Unsupported subgroup matrix config dimensions.");
@@ -266,13 +284,35 @@ Status ApplySubgroupMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Te
Tensor* y,
const uint32_t weight_index,
const Tensor* weight_index_indirect,
- const Tensor* prepacked_a) {
+ const Tensor* prepacked_a,
+ const Tensor* lora_t,
+ const Tensor* lora_b,
+ const Tensor* lora_b_scales,
+ uint32_t narrow_tile_m) {
const bool has_zero_points = zero_points != nullptr;
const bool has_bias = bias != nullptr;
const bool has_weight_idx_indirect = weight_index_indirect != nullptr;
const bool has_weight_idx = weight_index > 0 || has_weight_idx_indirect;
+ const bool has_qlora_up = lora_t != nullptr;
+ ORT_RETURN_IF_NOT((lora_b != nullptr) == has_qlora_up && (lora_b_scales != nullptr) == has_qlora_up,
+ "QLoRA up inputs must be provided together.");
auto [tile_m, tile_n, tile_size_k, workgroup_size] = SelectMatMulNBitsTiling(config, M, N, K, has_bias);
+ if (has_qlora_up && config.Is(16, 16, 16) && tile_m == 256) {
+ // The fused rank-32 tile needs another 16 KiB of shared memory. Cap BK so the
+ // wave64 base and up projections together stay within 32 KiB.
+ tile_size_k = std::min(tile_size_k, 64u);
+ }
+ if (narrow_tile_m != 0) {
+ ORT_ENFORCE(config.Is(8, 16, 16) && nbits == 8 && N == 32 &&
+ narrow_tile_m % config.M == 0 && !has_bias && !has_qlora_up);
+ tile_m = narrow_tile_m;
+ tile_n = 32;
+ workgroup_size = tile_m / config.M * config.subgroupSize;
+ }
+ if (config.Is(8, 16, 16) && config.resultComponentType == wgpu::SubgroupMatrixComponentType::F32) {
+ ORT_ENFORCE(narrow_tile_m != 0, "FP32 subgroup accumulation is implemented for the narrow QLoRA down tile.");
+ }
ORT_ENFORCE(workgroup_size <= context.DeviceLimits().maxComputeWorkgroupSizeX &&
workgroup_size <= context.DeviceLimits().maxComputeInvocationsPerWorkgroup,
"Subgroup matrix MatMulNBits workgroup size exceeds device limits: ", workgroup_size);
@@ -297,10 +337,12 @@ Status ApplySubgroupMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Te
// padded buffer instead of `y` (which is too short by this point); the crop-copy
// dispatch below moves the valid rows into `y`. This keeps the no-bias write-out
// free of any bounds-checked workgroup-scratch store.
- const bool has_tail_buffer = !has_bias && config.Is(8, 16, 16) && (M % tile_m != 0);
+ const bool has_tail_buffer = !has_bias && config.Is(8, 16, 16) &&
+ config.resultComponentType != wgpu::SubgroupMatrixComponentType::F32 &&
+ (M % tile_m != 0);
SubgroupMatrixMatMulNBitsProgram matmul_program{nbits, config, tile_m, tile_n, tile_size_k,
has_zero_points, has_bias, has_weight_idx,
- has_weight_idx_indirect, has_tail_buffer};
+ has_weight_idx_indirect, has_qlora_up, has_tail_buffer};
matmul_program.SetWorkgroupSize(workgroup_size);
// Pin kernels running on variable-size adapters to the subgroup size they were written for.
@@ -323,7 +365,7 @@ Status ApplySubgroupMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Te
static_cast(config.resultComponentType),
config.M, config.N, config.K, config.subgroupSize,
tile_m, tile_n, tile_size_k, has_zero_points, has_bias, has_weight_idx,
- has_weight_idx_indirect, has_tail_buffer);
+ has_weight_idx_indirect, has_qlora_up, has_tail_buffer);
if (has_zero_points) {
matmul_program.AddInput({zero_points, ProgramTensorMetadataDependency::None, {(zero_points->Shape().Size() + 3) / 4}, 4});
}
@@ -333,6 +375,11 @@ Status ApplySubgroupMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Te
if (has_weight_idx_indirect) {
matmul_program.AddInput({weight_index_indirect, ProgramTensorMetadataDependency::None});
}
+ if (has_qlora_up) {
+ matmul_program.AddInput({lora_t, ProgramTensorMetadataDependency::Type, 4});
+ matmul_program.AddInput({lora_b, ProgramTensorMetadataDependency::Type, 16});
+ matmul_program.AddInput({lora_b_scales, ProgramTensorMetadataDependency::Type});
+ }
Tensor tail_buffer;
if (has_tail_buffer) {
@@ -366,6 +413,34 @@ Status ApplySubgroupMatrixMatMulNBits(const Tensor* a, const Tensor* b, const Te
return Status::OK();
}
+Status ApplySubgroupMatrixMatMulNBitsQLoRA(
+ const Tensor* x, const Tensor* w, const Tensor* scales,
+ const Tensor* lora_a, const Tensor* lora_a_scales,
+ const Tensor* lora_b, const Tensor* lora_b_scales,
+ uint32_t M, uint32_t N, uint32_t K, const SubgroupMatrixConfig& config,
+ const SubgroupMatrixConfig& down_config,
+ onnxruntime::webgpu::ComputeContext& context, Tensor* t, Tensor* y) {
+ ORT_ENFORCE(config.Is(8, 16, 16) && config.needsPrepack);
+ ORT_ENFORCE(down_config.Is(8, 16, 16) && down_config.needsPrepack &&
+ down_config.componentType == config.componentType &&
+ down_config.resultComponentType == wgpu::SubgroupMatrixComponentType::F32 &&
+ down_config.subgroupSize == config.subgroupSize);
+ constexpr uint32_t kDownTileM = 32;
+ const auto base_tiling = SelectMatMulNBitsTiling(config, M, N, K, false);
+ const auto padded_M = std::max(CeilDiv(M, base_tiling.m) * base_tiling.m,
+ CeilDiv(M, kDownTileM) * kDownTileM);
+ // The layout depends on the subgroup matrix dimensions, not the workgroup tile.
+ // Both projections can read one packed X, padded for the larger workgroup tile.
+ Tensor packed_x;
+ ORT_RETURN_IF_ERROR(PrepackMatMulNBitsInput(x, M, K, padded_M, config, context, packed_x));
+ ORT_RETURN_IF_ERROR(ApplySubgroupMatrixMatMulNBits(
+ x, lora_a, lora_a_scales, nullptr, nullptr, M, 32, K, 8, K / 32, down_config, context, t,
+ 0, nullptr, &packed_x, nullptr, nullptr, nullptr, kDownTileM));
+ return ApplySubgroupMatrixMatMulNBits(
+ x, w, scales, nullptr, nullptr, M, N, K, 4, CeilDiv(K / 32, 2u) * 2u, config, context, y,
+ 0, nullptr, &packed_x, t, lora_b, lora_b_scales);
+}
+
bool CanApplySubgroupMatrixMatMulNBits(onnxruntime::webgpu::ComputeContext& context,
uint64_t accuracy_level,
uint32_t block_size,
@@ -402,12 +477,9 @@ bool CanApplySubgroupMatrixMatMulNBits(onnxruntime::webgpu::ComputeContext& cont
return false;
}
- // Every fp16 preference below has resultComponentType == F16, and the kernels declare
- // subgroup_matrix_result to match, so the accumulation inside
- // subgroupMatrixMultiplyAccumulate is f16 and there is no variant of this kernel that can
- // honour an f32 accumulator request. Decline the path instead of ignoring the option: the
- // caller then falls through to a kernel that does honour it. Only fp16 outputs are affected;
- // the fp32 config accumulates in f32 already.
+ // The FP16 configurations below accumulate the base projection in FP16. The separately
+ // selected FP32-accumulating QLoRA down tile does not support a general base projection.
+ // Fall back when FP32 accumulation is requested instead of ignoring the option.
if (is_fp16 && context.EnableMatmulFp32Accumulation()) {
return false;
}
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.h b/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.h
index a0e037152e4a2..c7f6a29b1ea4d 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.h
+++ b/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits.h
@@ -23,8 +23,10 @@ class SubgroupMatrixMatMulNBitsProgram final : public Program buf_b: array;
// Small staging buffer for edge-tile output (one 16x16 tile per subgroup)
var coopmat_stage: array;
+#if has_qlora_up
+const kQLoRARank: u32 = 32;
+var lora_a: array;
+#endif
+
#if n_bits == 4
// Dequantize one 16-element K chunk from one B row into buf_b.
fn dequant_b_to_buf(n_base: u32, k_idx: u32, n_idx: u32, k_chunk: u32) {
@@ -255,6 +261,84 @@ $MAIN {
workgroupBarrier();
}
+#if has_qlora_up
+ for (var load_unit = local_idx; load_unit < BM * (kQLoRARank / 4u);
+ load_unit += workgroup_size_x) {
+ let tile_m = load_unit / (kQLoRARank / 4u);
+ let r4 = load_unit % (kQLoRARank / 4u);
+ let global_m = global_base_m + tile_m;
+ let offset = tile_m * kQLoRARank + r4 * 4u;
+ if (global_m < uniforms.M) {
+ let values = lora_t[global_m * (kQLoRARank / 4u) + r4];
+ for (var i = 0u; i < 4u; i++) {
+ lora_a[offset + i] = f16(values[i]);
+ }
+ } else {
+ for (var i = 0u; i < 4u; i++) {
+ lora_a[offset + i] = f16(0.0);
+ }
+ }
+ }
+ for (var load_unit = local_idx; load_unit < BN * (kQLoRARank / 4u);
+ load_unit += workgroup_size_x) {
+ let tile_n = load_unit / (kQLoRARank / 4u);
+ let r4 = load_unit % (kQLoRARank / 4u);
+ let global_n = global_base_n + tile_n;
+ let offset = tile_n * SHMEM_STRIDE + r4 * 4u;
+ if (global_n < uniforms.N) {
+ let values = qlora_weight(global_n, r4);
+ for (var i = 0u; i < 4u; i++) {
+ buf_b[offset + i] = f16(values[i]);
+ }
+ } else {
+ for (var i = 0u; i < 4u; i++) {
+ buf_b[offset + i] = f16(0.0);
+ }
+ }
+ }
+ workgroupBarrier();
+
+ for (var k = 0u; k < kQLoRARank; k += kSgMatK) {
+ let b_base = (warp_c * WN) * SHMEM_STRIDE + k;
+ var b0: subgroup_matrix_right = subgroupMatrixLoad, col_major>(
+ &buf_b, b_base, SHMEM_STRIDE);
+ var b1: subgroup_matrix_right = subgroupMatrixLoad, col_major>(
+ &buf_b, b_base + 16u * SHMEM_STRIDE, SHMEM_STRIDE);
+ var b2: subgroup_matrix_right = subgroupMatrixLoad, col_major>(
+ &buf_b, b_base + 32u * SHMEM_STRIDE, SHMEM_STRIDE);
+ var b3: subgroup_matrix_right = subgroupMatrixLoad, col_major>(
+ &buf_b, b_base + 48u * SHMEM_STRIDE, SHMEM_STRIDE);
+
+ let a_base = (warp_r * WM) * kQLoRARank + k;
+ var a0: subgroup_matrix_left = subgroupMatrixLoad, row_major>(
+ &lora_a, a_base, kQLoRARank);
+ var a1: subgroup_matrix_left = subgroupMatrixLoad, row_major>(
+ &lora_a, a_base + 16u * kQLoRARank, kQLoRARank);
+ var a2: subgroup_matrix_left = subgroupMatrixLoad, row_major>(
+ &lora_a, a_base + 32u * kQLoRARank, kQLoRARank);
+ var a3: subgroup_matrix_left = subgroupMatrixLoad, row_major>(
+ &lora_a, a_base + 48u * kQLoRARank, kQLoRARank);
+
+ c00 = subgroupMatrixMultiplyAccumulate(a0, b0, c00);
+ c01 = subgroupMatrixMultiplyAccumulate(a0, b1, c01);
+ c02 = subgroupMatrixMultiplyAccumulate(a0, b2, c02);
+ c03 = subgroupMatrixMultiplyAccumulate(a0, b3, c03);
+ c10 = subgroupMatrixMultiplyAccumulate(a1, b0, c10);
+ c11 = subgroupMatrixMultiplyAccumulate(a1, b1, c11);
+ c12 = subgroupMatrixMultiplyAccumulate(a1, b2, c12);
+ c13 = subgroupMatrixMultiplyAccumulate(a1, b3, c13);
+ c20 = subgroupMatrixMultiplyAccumulate(a2, b0, c20);
+ c21 = subgroupMatrixMultiplyAccumulate(a2, b1, c21);
+ c22 = subgroupMatrixMultiplyAccumulate(a2, b2, c22);
+ c23 = subgroupMatrixMultiplyAccumulate(a2, b3, c23);
+ c30 = subgroupMatrixMultiplyAccumulate(a3, b0, c30);
+ c31 = subgroupMatrixMultiplyAccumulate(a3, b1, c31);
+ c32 = subgroupMatrixMultiplyAccumulate(a3, b2, c32);
+ c33 = subgroupMatrixMultiplyAccumulate(a3, b3, c33);
+ }
+ workgroupBarrier();
+#endif
+
// Write out results
let dr = global_base_m + warp_r * WM;
let dc = global_base_n + warp_c * WN;
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits_8x16x16.wgsl.template b/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits_8x16x16.wgsl.template
index 9f0a2c4e3b7db..2cf337add7fd4 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits_8x16x16.wgsl.template
+++ b/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits_8x16x16.wgsl.template
@@ -13,8 +13,10 @@
// K dimension iterated in kChunkSizeK(32) blocks
#param n_bits
+#param acc_f32
#param has_zero_points
#param has_bias
+#param has_qlora_up
#param has_tail_buffer
#param has_weight_idx
#param has_weight_idx_indirect
@@ -55,17 +57,27 @@ const kSgMatCountK : u32 = kChunkSizeK / kSgMatDimK; // Number of subgroup_mat
alias SgMatLeft = subgroup_matrix_left;
alias SgMatRight = subgroup_matrix_right;
+#if acc_f32
+alias SgMatResult = subgroup_matrix_result;
+// The narrow down projection accumulates over long K in FP32, then rounds T to FP16.
+var result_f32 : array;
+#else
alias SgMatResult = subgroup_matrix_result;
+#endif
// Shared local memory for dequantized B weights [kTileN, kChunkSizeK], column-major
var tile_b : array;
#if has_bias
-// Scratch space for bounds-checked write-out with bias addition, [kTileM, kTileN],
-// row-major. The no-bias path never needs this - see `has_tail_buffer` below.
+// Scratch space for bounds-checked write-out with bias addition, [kTileM, kTileN], row-major.
var scratch : array;
#endif
+#if has_qlora_up
+const kQLoRARank : u32 = 32;
+var lora_a : array;
+#endif
+
// --- Data Loading Functions ---
// scale_offset is resolved before the K loop, not recomputed for each load.
@@ -176,8 +188,74 @@ $MAIN {
workgroupBarrier();
}
+#if has_qlora_up
+ // Reuse the subgroup-matrix accumulators for the rank-32 adapter-up product.
+ // Loading the row-major adapter activation into workgroup memory avoids the
+ // standalone subgroup layout and adapter-up dispatches.
+ for (var load_unit = local_idx; load_unit < kTileM * (kQLoRARank / 4u);
+ load_unit += workgroup_size_x) {
+ let row_in_tile = load_unit / (kQLoRARank / 4u);
+ let r4 = load_unit % (kQLoRARank / 4u);
+ let global_m = global_base_m + row_in_tile;
+ if (global_m < uniforms.M) {
+ let values = lora_t[global_m * (kQLoRARank / 4u) + r4];
+ let offset = row_in_tile * kQLoRARank + r4 * 4u;
+ for (var i = 0u; i < 4u; i++) {
+ lora_a[offset + i] = f16(values[i]);
+ }
+ }
+ }
+ for (var load_unit = local_idx; load_unit < kTileN * (kQLoRARank / 4u);
+ load_unit += workgroup_size_x) {
+ let col_in_tile = load_unit / (kQLoRARank / 4u);
+ let r4 = load_unit % (kQLoRARank / 4u);
+ let global_n = global_base_n + col_in_tile;
+ if (global_n < uniforms.N) {
+ let values = qlora_weight(global_n, r4);
+ let offset = col_in_tile * kQLoRARank + r4 * 4u;
+ for (var i = 0u; i < 4u; i++) {
+ tile_b[offset + i] = f16(values[i]);
+ }
+ }
+ }
+ workgroupBarrier();
+
+ let lora_a_base = sg_idx * kSgMatDimM * kQLoRARank;
+ for (var k_idx = 0u; k_idx < kQLoRARank; k_idx += kSgMatDimK) {
+ var sg_mat_a: SgMatLeft = subgroupMatrixLoad(&lora_a,
+ lora_a_base + k_idx,
+ kQLoRARank);
+ var sg_mat_offset_b = k_idx;
+ for (var sg_mat_n_idx = 0u; sg_mat_n_idx < kSgMatCountN; sg_mat_n_idx++) {
+ var sg_mat_b: SgMatRight = subgroupMatrixLoad(&tile_b,
+ sg_mat_offset_b,
+ kQLoRARank);
+ sg_mat_offset_b += kSgMatStrideN;
+ sg_mat_acc[sg_mat_n_idx] =
+ subgroupMatrixMultiplyAccumulate(sg_mat_a, sg_mat_b, sg_mat_acc[sg_mat_n_idx]);
+ }
+ }
+ workgroupBarrier();
+#endif
+
// Write out
-#if has_bias
+#if acc_f32
+ let scratch_store_base = sg_idx * kSgMatDimM * kTileN;
+ for (var sg_mat_n_idx = 0u; sg_mat_n_idx < kSgMatCountN; sg_mat_n_idx++) {
+ subgroupMatrixStore(&result_f32,
+ scratch_store_base + sg_mat_n_idx * kSgMatDimN,
+ sg_mat_acc[sg_mat_n_idx],
+ kTileN);
+ }
+ workgroupBarrier();
+ for (var i = local_idx; i < kTileM * kTileN; i += workgroup_size_x) {
+ let row = global_base_m + i / kTileN;
+ let col = global_base_n + i % kTileN;
+ if (row < uniforms.M && col < uniforms.N) {
+ output.setByOffset(row * uniforms.N + col, output_element_t(result_f32[i]));
+ }
+ }
+#elif has_bias
let scratch_store_base = sg_idx * kSgMatDimM * kTileN;
for (var sg_mat_n_idx = 0u; sg_mat_n_idx < kSgMatCountN; sg_mat_n_idx++) {
subgroupMatrixStore(&scratch,
@@ -205,7 +283,7 @@ $MAIN {
#endif
for (var i : u32 = 0; i < ElemsPerThreadRow; i++) {
if (out_global_n_base + i < uniforms.N) {
- let val = f16(scratch[scratch_base + i]) + bias[bias_offset + out_global_n_base + i];
+ let val = output_element_t(scratch[scratch_base + i]) + bias[bias_offset + out_global_n_base + i];
output.setByOffset(out_base + i, val);
}
}
diff --git a/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits_8x8x8.wgsl.template b/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits_8x8x8.wgsl.template
index f28360af79c45..6f8927903124e 100644
--- a/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits_8x8x8.wgsl.template
+++ b/onnxruntime/contrib_ops/webgpu/quantization/subgroup_matrix_matmul_nbits_8x8x8.wgsl.template
@@ -7,6 +7,7 @@
#param n_bits
#param has_zero_points
#param has_bias
+#param has_qlora_up
#param has_weight_idx
#param has_weight_idx_indirect
@@ -211,6 +212,53 @@ $MAIN {
workgroupBarrier();
}
+#if has_qlora_up
+ for (var idx = local_idx; idx < tile_rows * tile_k; idx += workgroup_size_x) {
+ let row = idx / tile_k;
+ let col = idx % tile_k;
+ let global_row = a_global_base + row;
+ if (global_row < uniforms.M) {
+ tile_A[idx] = compute_precision(lora_t[global_row * 8u + col / 4u][col % 4u]);
+ }
+ }
+ for (var idx = local_idx; idx < tile_cols * tile_k; idx += workgroup_size_x) {
+ let row = idx / tile_k;
+ let col = idx % tile_k;
+ let global_col = b_global_base + row;
+ if (global_col < uniforms.N) {
+ tile_B[idx] = compute_precision(qlora_weight(global_col, col / 4u)[col % 4u]);
+ }
+ }
+ workgroupBarrier();
+
+ for (var step: u32 = 0; step < tile_k; step += 8u) {
+ let matrix_a_offset = subtile_idy * subtile_rows * tile_k + step;
+ var matA0: subgroup_matrix_left = subgroupMatrixLoad, row_major>(
+ &tile_A, matrix_a_offset, tile_k);
+ var matA1: subgroup_matrix_left = subgroupMatrixLoad, row_major>(
+ &tile_A, matrix_a_offset + 8u * tile_k, tile_k);
+ let matrix_b_offset = subtile_idx * subtile_cols * tile_k + step;
+ var matB0: subgroup_matrix_right = subgroupMatrixLoad, col_major>(
+ &tile_B, matrix_b_offset, tile_k);
+ var matB1: subgroup_matrix_right = subgroupMatrixLoad, col_major>(
+ &tile_B, matrix_b_offset + 8u * tile_k, tile_k);
+ var matB2: subgroup_matrix_right = subgroupMatrixLoad, col_major>(
+ &tile_B, matrix_b_offset + 16u * tile_k, tile_k);
+ var matB3: subgroup_matrix_right = subgroupMatrixLoad, col_major>(
+ &tile_B, matrix_b_offset + 24u * tile_k, tile_k);
+
+ matC00 = subgroupMatrixMultiplyAccumulate(matA0, matB0, matC00);
+ matC01 = subgroupMatrixMultiplyAccumulate(matA0, matB1, matC01);
+ matC02 = subgroupMatrixMultiplyAccumulate(matA0, matB2, matC02);
+ matC03 = subgroupMatrixMultiplyAccumulate(matA0, matB3, matC03);
+ matC10 = subgroupMatrixMultiplyAccumulate(matA1, matB0, matC10);
+ matC11 = subgroupMatrixMultiplyAccumulate(matA1, matB1, matC11);
+ matC12 = subgroupMatrixMultiplyAccumulate(matA1, matB2, matC12);
+ matC13 = subgroupMatrixMultiplyAccumulate(matA1, matB3, matC13);
+ }
+ workgroupBarrier();
+#endif
+
// Write out
// Write out top block
subgroupMatrixStore(&scratch[subtile_id][0], 0, matC00, 8);
diff --git a/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc b/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc
index d1cf54166e658..34ed6bb9e300e 100644
--- a/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc
+++ b/onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc
@@ -69,6 +69,7 @@ static const BuildKernelCreateInfoFn build_kernel_create_info_function_table[] =
BuildKernelCreateInfo,
BuildKernelCreateInfo,
BuildKernelCreateInfo,
+ BuildKernelCreateInfo,
BuildKernelCreateInfo,
BuildKernelCreateInfo,
BuildKernelCreateInfo,
diff --git a/onnxruntime/core/graph/contrib_ops/contrib_defs.cc b/onnxruntime/core/graph/contrib_ops/contrib_defs.cc
index fe19adb003419..a53cdca974176 100644
--- a/onnxruntime/core/graph/contrib_ops/contrib_defs.cc
+++ b/onnxruntime/core/graph/contrib_ops/contrib_defs.cc
@@ -4155,6 +4155,106 @@ Empty packed weights are currently supported by the CPU and WebGPU execution pro
}
});
+ ONNX_CONTRIB_OPERATOR_SCHEMA(MatMulNBitsQLoRA)
+ .SetDomain(kMSDomain)
+ .SinceVersion(1)
+ .SetDoc(
+ "Computes MatMulNBits(X,W,scales) + MatMulNBits(MatMulNBits(X,lora_A,lora_A_scales),lora_B,lora_B_scales). "
+ "Each projection follows MatMulNBits block quantization and bit packing, with implicit zero points "
+ "2^(bits-1) for the base and 2^(lora_bits-1) for the adapters. No bias or explicit zero points are applied. "
+ "The adapter rank R may be any positive integer and need not be a multiple of lora_block_size. "
+ "Partial final blocks are padded; only the logical K or R elements participate in each projection. "
+ "An empty rank dimension in either packed adapter disables the adapter, while scale defaults are retained. "
+ "Intermediate projections and block scales use the activation type before the final addition. "
+ "Adapters remain replaceable. Execution providers may support a subset of the defined types, "
+ "quantization settings, and ranks.")
+ .Attr("K", "Positive input feature dimension of the base and down-projection weight matrices.",
+ AttributeProto::INT)
+ .Attr("N", "Positive output feature dimension of the base and up-projection weight matrices.",
+ AttributeProto::INT)
+ .Attr("bits", "Base weight bit width, following MatMulNBits (2, 4, or 8).", AttributeProto::INT, int64_t{4})
+ .Attr("block_size", "Base quantization block size along K, following MatMulNBits: a power of two, at least 16.",
+ AttributeProto::INT, int64_t{32})
+ .Attr("accuracy_level",
+ "Accuracy policy following MatMulNBits: 0 (unset), 1 (fp32), 2 (fp16), 3 (bf16), or 4 (int8). "
+ "Applies to the base and eligible adapter down projections; the up projection uses level 0.",
+ AttributeProto::INT, int64_t{0})
+ .Attr("lora_bits", "Adapter weight bit width, following MatMulNBits (2, 4, or 8).",
+ AttributeProto::INT, int64_t{8})
+ .Attr("lora_block_size",
+ "Adapter quantization block size along K for lora_A and R for lora_B: a power of two, at least 16.",
+ AttributeProto::INT, int64_t{32})
+ .Input(0, "X", "Input of rank at least one, with last dimension K, following MatMulNBits.", "T")
+ .Input(1, "W", "Packed base weights [N,ceil(K/block_size),block_size*bits/8].", "TQ")
+ .Input(2, "scales", "Base block scales [N,ceil(K/block_size)]; may be flattened.", "T")
+ .Input(3, "lora_A",
+ "Packed down weights [R_A,ceil(K/lora_block_size),lora_block_size*lora_bits/8], R_A=R or 0.", "TQ")
+ .Input(4, "lora_A_scales",
+ "Down block scales [R,ceil(K/lora_block_size)]; retained when the adapter is empty.", "T")
+ .Input(5, "lora_B",
+ "Packed up weights [N,R_blocks,lora_block_size*lora_bits/8], R_blocks=ceil(R/lora_block_size) or 0.", "TQ")
+ .Input(6, "lora_B_scales",
+ "Up block scales [N,ceil(R/lora_block_size)]; retained when the adapter is empty.", "T")
+ .Output(0, "Y", "Input shape with the last dimension replaced by N.", "T")
+ .TypeConstraint("T", {"tensor(float16)", "tensor(float)"},
+ "Activations and block scales share the same floating-point type (FP16 or FP32).")
+ .TypeConstraint("TQ", {"tensor(uint8)"}, "Packed quantized weights.")
+ .TypeAndShapeInferenceFunction([](ONNX_NAMESPACE::InferenceContext& ctx) {
+ const auto k = getAttribute(ctx, "K", -1);
+ const auto n = getAttribute(ctx, "N", -1);
+ const auto lora_bits = getAttribute(ctx, "lora_bits", 8);
+ const auto lora_block_size = getAttribute(ctx, "lora_block_size", 32);
+ const auto valid_quantization = [](int64_t bits, int64_t block_size) {
+ return (bits == 2 || bits == 4 || bits == 8) && block_size >= 16 &&
+ (block_size & (block_size - 1)) == 0;
+ };
+ if (k <= 0 || n <= 0 ||
+ !valid_quantization(getAttribute(ctx, "bits", 4), getAttribute(ctx, "block_size", 32)) ||
+ !valid_quantization(lora_bits, lora_block_size)) {
+ fail_shape_inference(
+ "MatMulNBitsQLoRA requires positive K and N, bit widths 2, 4, or 8, "
+ "and power-of-two block sizes of at least 16.");
+ }
+ propagateElemTypeFromInputToOutput(ctx, 0, 0);
+ MatmulWithQuantWeightShapeInference(ctx, k, n, true);
+ int64_t rank = -1;
+ for (size_t index : {size_t{3}, size_t{4}, size_t{5}, size_t{6}}) {
+ if (!hasInputShape(ctx, index)) continue;
+ const auto& shape = getInputShape(ctx, index);
+ const bool packed = index == 3 || index == 5;
+ if (shape.dim_size() != (packed ? 3 : 2)) fail_shape_inference("Invalid quantized adapter rank.");
+ if ((index == 3 || index == 4) && shape.dim(0).has_dim_value()) {
+ const auto value = shape.dim(0).dim_value();
+ if (index == 3 && value == 0) continue; // Empty packed defaults retain the scale tensor's rank.
+ if (value <= 0 || (rank > 0 && value != rank)) {
+ fail_shape_inference("Inconsistent adapter rank between lora_A and lora_A_scales.");
+ }
+ rank = value;
+ }
+ }
+ const auto k_blocks = k / lora_block_size + (k % lora_block_size != 0);
+ const auto r_blocks = rank < 0 ? -1 : rank / lora_block_size + (rank % lora_block_size != 0);
+ const auto blob_size = (lora_block_size / 8) * lora_bits;
+ const int64_t dims[4][3] = {
+ {rank, k_blocks, blob_size},
+ {rank, k_blocks, 0},
+ {n, r_blocks, blob_size},
+ {n, r_blocks, 0},
+ };
+ for (size_t index : {size_t{3}, size_t{4}, size_t{5}, size_t{6}}) {
+ if (!hasInputShape(ctx, index)) continue;
+ const auto& shape = getInputShape(ctx, index);
+ for (int axis = 0; axis < shape.dim_size(); ++axis) {
+ const auto& dim = shape.dim(axis);
+ const bool empty_axis = (index == 3 && axis == 0) || (index == 5 && axis == 1);
+ if (dim.has_dim_value() && dims[index - 3][axis] >= 0 && dim.dim_value() != dims[index - 3][axis] &&
+ !(empty_axis && dim.dim_value() == 0)) {
+ fail_shape_inference("Invalid quantized adapter dimensions.");
+ }
+ }
+ }
+ });
+
static const char* MatMulNBitsMlp_ver1_doc = R"DOC(
MatMulNBitsMlp fuses two MatMulNBits projections that share the same input and computes
diff --git a/onnxruntime/core/optimizer/graph_transformer_utils.cc b/onnxruntime/core/optimizer/graph_transformer_utils.cc
index cc2c49dbc49e3..64b456a5c9f77 100644
--- a/onnxruntime/core/optimizer/graph_transformer_utils.cc
+++ b/onnxruntime/core/optimizer/graph_transformer_utils.cc
@@ -60,6 +60,7 @@
#include "core/optimizer/group_query_attention_pre_norm_fusion.h"
#include "core/optimizer/matmul_nbits_qkv_fusion.h"
#include "core/optimizer/matmul_nbits_mlp_fusion.h"
+#include "core/optimizer/matmul_nbits_qlora_fusion.h"
#include "core/optimizer/matmul_bn_fusion.h"
#include "core/optimizer/matmul_integer_to_float.h"
#include "core/optimizer/matmul_scale_fusion.h"
@@ -465,6 +466,7 @@ InlinedVector> GenerateTransformers(
transformers.emplace_back(std::make_unique(
InlinedHashSet{onnxruntime::kCudaExecutionProvider,
onnxruntime::kWebGpuExecutionProvider}));
+ bool has_matmul_nbits_qlora_kernel = false;
bool has_matmul_nbits_mlp_kernel = false;
bool has_matmul_nbits_qkv_kernel = false;
if (execution_providers != nullptr) {
@@ -482,11 +484,15 @@ InlinedVector> GenerateTransformers(
nullptr)
.IsOK();
};
+ has_matmul_nbits_qlora_kernel = has_webgpu_kernel("MatMulNBitsQLoRA");
has_matmul_nbits_mlp_kernel = has_webgpu_kernel("MatMulNBitsMlp");
has_matmul_nbits_qkv_kernel = has_webgpu_kernel("MatMulNBitsQkv");
}
}
}
+ if (has_matmul_nbits_qlora_kernel) {
+ transformers.emplace_back(std::make_unique());
+ }
if (has_matmul_nbits_mlp_kernel) {
transformers.emplace_back(std::make_unique(
InlinedHashSet{onnxruntime::kWebGpuExecutionProvider}));
diff --git a/onnxruntime/core/optimizer/matmul_nbits_qlora_fusion.cc b/onnxruntime/core/optimizer/matmul_nbits_qlora_fusion.cc
new file mode 100644
index 0000000000000..971e13221d6fe
--- /dev/null
+++ b/onnxruntime/core/optimizer/matmul_nbits_qlora_fusion.cc
@@ -0,0 +1,144 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#include "core/optimizer/matmul_nbits_qlora_fusion.h"
+
+#include "core/graph/graph_utils.h"
+#include "core/graph/node_attr_utils.h"
+#include "core/optimizer/matmul_nbits_fusion_utils.h"
+#include "core/optimizer/utils.h"
+
+namespace onnxruntime {
+namespace {
+
+const Node* Producer(const Graph& graph, const Node& node, int input) {
+ const auto* edge = graph_utils::GetInputEdge(node, input);
+ return edge ? graph.GetNode(edge->GetNode().Index()) : nullptr;
+}
+
+bool Private(const Graph& graph, const Node& node) {
+ return !graph.NodeProducesGraphOutput(node) && optimizer_utils::CheckOutputEdges(graph, node, 1);
+}
+
+bool Shape(const NodeArg& arg, std::initializer_list expected, int empty_axis = -1) {
+ const auto* shape = arg.Shape();
+ if (!shape || shape->dim_size() != static_cast(expected.size())) return false;
+ int index = 0;
+ for (auto value : expected) {
+ const auto& dim = shape->dim(index);
+ if (index++ == empty_axis && (!dim.has_dim_value() || dim.dim_value() == 0)) continue;
+ if (!dim.has_dim_value() || dim.dim_value() != value) return false;
+ }
+ return true;
+}
+
+bool AdapterShape(const Graph& graph, const NodeArg& arg, std::initializer_list expected, int empty_axis) {
+ // K/N attributes establish the logical rank even when an overridable default
+ // is empty. Only the axis used by the QLoRA empty-adapter convention may vary.
+ if (!Shape(arg, expected, empty_axis)) return false;
+ const ONNX_NAMESPACE::TensorProto* init = nullptr;
+ if (graph.GetInitializedTensor(arg.Name(), init)) {
+ if (init->dims_size() != static_cast(expected.size())) return false;
+ int index = 0;
+ for (auto value : expected) {
+ const auto dim = init->dims(index);
+ if (dim != value && !(index == empty_axis && dim == 0)) return false;
+ ++index;
+ }
+ }
+ return true;
+}
+
+bool Projection(const Node* node, int64_t bits) {
+ using namespace matmul_nbits_fusion_utils;
+ return node && graph_utils::IsSupportedOptypeVersionAndDomain(*node, "MatMulNBits", {1}, kMSDomain) &&
+ node->GetExecutionProviderType() == kWebGpuExecutionProvider && GetIntAttr(*node, "bits", 4) == bits &&
+ GetIntAttr(*node, "block_size", -1, true) == 32 && GetIntAttr(*node, "weight_prepacked", 0) == 0 &&
+ !HasInput(*node, 3) && !HasInput(*node, 4) && !HasInput(*node, 5) &&
+ (bits != 8 || GetIntAttr(*node, "accuracy_level", 0) == 0);
+}
+
+} // namespace
+
+Status MatMulNBitsQLoRAFusion::ApplyImpl(Graph& graph, bool& modified, int graph_level,
+ const logging::Logger& logger) const {
+ using namespace matmul_nbits_fusion_utils;
+ GraphViewer viewer(graph);
+ for (auto index : viewer.GetNodesInTopologicalOrder()) {
+ auto* add = graph.GetNode(index);
+ if (!add) continue;
+ ORT_RETURN_IF_ERROR(Recurse(*add, modified, graph_level, logger));
+ if (!graph_utils::IsSupportedOptypeVersionAndDomain(*add, "Add", {7, 13, 14}) ||
+ add->GetExecutionProviderType() != kWebGpuExecutionProvider)
+ continue;
+ auto* base = Producer(graph, *add, 0);
+ auto* up = Producer(graph, *add, 1);
+ if (!Projection(base, 4)) std::swap(base, up);
+ if (!Projection(base, 4) || !Projection(up, 8)) continue;
+ auto* down = Producer(graph, *up, 0);
+ if (!Projection(down, 8) || down->InputDefs()[0] != base->InputDefs()[0] || !Private(graph, *base) ||
+ !Private(graph, *down) || !Private(graph, *up))
+ continue;
+ const int64_t k = GetIntAttr(*base, "K", -1, true);
+ const int64_t n = GetIntAttr(*base, "N", -1, true);
+ if (k <= 0 || k % 32 != 0 || n <= 0 || GetIntAttr(*down, "K", -1, true) != k ||
+ GetIntAttr(*down, "N", -1, true) != 32 || GetIntAttr(*up, "K", -1, true) != 32 ||
+ GetIntAttr(*up, "N", -1, true) != n)
+ continue;
+ InlinedVector