Repository: microsoft/onnxruntime
Pull request #24142: [WebNN] Support MatMulNBits op
Author: login: Honry
Labels: name: ep:WebNN
description: WebNN execution provider
Created: 2025-03-24T07:53:20Z | Merged: 2025-04-09T19:03:43Z

MatMulNBits op can be simply emulated by DequantizeLinear + Transpose + MatMul and currently only 4-bit quantization is supported.

Thus the B and zero_points (if present) inputs must be known as initializers with data type 'uint8' and we need to register them as 'uint4' WebNN constant.

Typically, all initializers are registered as WebNN constants in one step via `ModelBuilder::RegisterInitializers` before constructing the WebNN graph. However, due to WebNN doesn't support cast to 'uint4', we need to defer the registration of these two inputs until the `MatMulNBitsBuilder::AddToModelBuilderImpl` is invoked.


Diff:
diff --git a/js/web/docs/webnn-operators.md b/js/web/docs/webnn-operators.md
index a6a2ecdf6f467..1c0a462822835 100644
--- a/js/web/docs/webnn-operators.md
+++ b/js/web/docs/webnn-operators.md
@@ -63,6 +63,7 @@ platforms. Check the [WebNN status](https://webmachinelearning.github.io/webnn-s
 | LRN | ai.onnx(7-12, 13+) | pad, averagePool2d, transpose, add, mul, pow, div | |
 | LSTM | ai.onnx(7-13, 14-21, 22+) | lstm | Only supports 'layout' == 0, 'input_forget' == 0. 'clip' is not supported. The activation functions in 'activations' must be one of 'Relu', 'Tanh', 'Sigmoid'. Forward and backward activations must be the same if bidirectional. 'sequence_lens' if present should be constant with values equal to the first dimension length of input 'X' |
 | MatMul | ai.onnx(7-8, 9-12, 13+) | matmul | |
+| MatMulNBits | com.microsoft(1+) | add, dequantizeLinear, matmul, reshape, transpose | Inputs 'B' and 'zero_points' (if present) should be constants, input 'g_idx' is not supported, only bits=4 is supported |
 | Max | ai.onnx(7, 8-11, 12, 13+) | max | |
 | MaxPool | ai.onnx(7, 8-9, 10, 11, 12+) | maxPool2d | Only supports 4-D input, 2-D 'kernel_shape', 'storage_order' != 1, one output |
 | Min | ai.onnx(7, 8-11, 12, 13+) | min | |
diff --git a/onnxruntime/core/providers/webnn/builders/helper.cc b/onnxruntime/core/providers/webnn/builders/helper.cc
index e0d82b26c2174..c6abcf9e50e6b 100644
--- a/onnxruntime/core/providers/webnn/builders/helper.cc
+++ b/onnxruntime/core/providers/webnn/builders/helper.cc
@@ -272,22 +272,23 @@ bool IsMLTensorSupported() {
   return is_supported;
 }
 
-// Convert int8 to uint4/int4 (stored as uint8)
-uint8_t PackInt8ToUint8AsNibble(int8_t value, const int32_t& data_type) {
-  uint8_t result = 0;
+// Convert int8 to uint4/int4 (stored as uint8), used for creating WebNN Constant
+// with same value in both high and low nibbles for uint4/int4 data type.
+uint8_t PackInt8ToUint8DoubledNibbles(int8_t value, const int32_t& data_type) {
   if (data_type == ONNX_NAMESPACE::TensorProto_DataType_UINT4) {
     if (value < 0 || value > 15) {
       ORT_THROW("Value cannot be safely converted to uint4.");
     }
-    result |= (static_cast<uint8_t>(value) << 4);
   } else {
     if (value < -8 || value > 7) {
       ORT_THROW("Value cannot be safely converted to int4.");
     }
-    result |= (value << 4);
   }
 
-  return result;
+  // Explicit conversion + truncate to lower 4 bits
+  const uint8_t result = static_cast<uint8_t>(value) & 0x0F;
+  // Duplicate the 4-bit value to both high and low nibbles
+  return (result << 4) | result;
 }
 
 // Convert float32 to float16 (stored as uint16)
diff --git a/onnxruntime/core/providers/webnn/builders/helper.h b/onnxruntime/core/providers/webnn/builders/helper.h
index 95c4b79053a1f..de6b5ff229e05 100644
--- a/onnxruntime/core/providers/webnn/builders/helper.h
+++ b/onnxruntime/core/providers/webnn/builders/helper.h
@@ -200,6 +200,7 @@ std::unordered_set<const Node*> GetSupportedNodes(const GraphViewer& graph_viewe
 // Some ONNX ops are supported by decomposed WebNN ops.
 const std::map<std::string_view, std::vector<std::string_view>> decomposed_op_map = {
     {"LRN", {"add", "averagePool2d", "div", "mul", "pad", "pow", "transpose"}},
+    {"MatMulNBits", {"add", "dequantizeLinear", "matmul", "reshape", "transpose"}},
     {"RotaryEmbedding", {"add", "concat", "gather", "mul", "reshape", "split"}},
     {"SimplifiedLayerNormalization", {"add", "div", "mul", "pow", "reduceMean", "sqrt"}},
     {"SkipSimplifiedLayerNormalization", {"add", "div", "mul", "pow", "reduceMean", "sqrt"}},
@@ -386,7 +387,7 @@ bool SetWebnnDataType(emscripten::val& desc, const int32_t data_type);
 
 bool IsMLTensorSupported();
 
-uint8_t PackInt8ToUint8AsNibble(int8_t value, const int32_t& data_type);
+uint8_t PackInt8ToUint8DoubledNibbles(int8_t value, const int32_t& data_type);
 uint16_t PackFloat32ToUint16AsFloat16(float value);
 
 }  // namespace webnn
diff --git a/onnxruntime/core/providers/webnn/builders/impl/matMulNBits_op_builder.cc b/onnxruntime/core/providers/webnn/builders/impl/matMulNBits_op_builder.cc
new file mode 100644
index 0000000000000..072b983effb1a
--- /dev/null
+++ b/onnxruntime/core/providers/webnn/builders/impl/matMulNBits_op_builder.cc
@@ -0,0 +1,255 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Intel Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#include "core/providers/shared/utils/utils.h"
+#include "core/providers/webnn/builders/helper.h"
+#include "core/providers/webnn/builders/model_builder.h"
+#include "core/providers/webnn/builders/op_builder_factory.h"
+
+#include "base_op_builder.h"
+
+namespace onnxruntime {
+namespace webnn {
+
+class MatMulNBitsBuilder : public BaseOpBuilder {
+  // Add operator related.
+ public:
+  void AddInitializersToSkip(ModelBuilder& model_builder, const Node& node) const override;
+
+ private:
+  Status AddToModelBuilderImpl(ModelBuilder& model_builder, const Node& node,
+                               const logging::Logger& logger) const override ORT_MUST_USE_RESULT;
+
+  // Operator support related.
+ private:
+  bool IsOpSupportedImpl(const GraphViewer& graph_viewer, const Node& node,
+                         const WebnnDeviceType /* device_type */, const logging::Logger& logger) const override;
+  bool HasSupportedInputsImpl(const GraphViewer&, const Node& node,
+                              const emscripten::val& wnn_limits, const logging::Logger& logger) const override;
+  bool HasSupportedOutputsImpl(const Node& node, const emscripten::val& wnn_limits,
+                               const logging::Logger& logger) const override;
+};
+
+void MatMulNBitsBuilder::AddInitializersToSkip(ModelBuilder& model_builder, const Node& node) const {
+  // Inputs B and zero_points (if present) must be initializers. If they are of type uint8,
+  // they should be stored as uint4 constants in WebNN. Therefore, we skip them here and
+  // delay their registration as WebNN constants.
+  const auto& input_defs = node.InputDefs();
+  if (input_defs[1]->TypeAsProto()->tensor_type().elem_type() == ONNX_NAMESPACE::TensorProto_DataType_UINT8) {
+    model_builder.AddInitializerToSkip(input_defs[1]->Name());  // B
+    if (TensorExists(input_defs, 3)) {
+      model_builder.AddInitializerToSkip(input_defs[3]->Name());  // zero_points
+    }
+  }
+}
+
+// WebNN doesn't provide a dedicated op for MatMulNBits, it can be simply decomposed by
+// DequantizeLinear + Transpose + MatMul. Given that the CPU EP currently only supports
+// 4-bit quantization, we only handle 4-bit quantization here.
+//
+// To align with WebNN's dequantizeLinear op contraints, the following transformations are
+// required for MatMulNBits inputs:
+// 1. B: must be a constant initializer and registered as a 'uint4' WebNN constant with shape
+//       [N, n_blocks_per_col, blob_size * 2].
+// 2. scales: reshape it to [N, n_blocks_per_col, 1].
+// 3. zero_points: it has the same shape as reshaped scales. If it presents, it must be a
+//                 constant initializer and registered as a 'uint4' WebNN constant.
+//                 Otherwise, it must be registered as a 'uint4' WebNN constant with default value 8.
+Status MatMulNBitsBuilder::AddToModelBuilderImpl(ModelBuilder& model_builder,
+                                                 const Node& node,
+                                                 const logging::Logger& logger) const {
+  const auto& input_defs = node.InputDefs();
+  const auto& output_defs = node.OutputDefs();
+  const auto& initializers = model_builder.GetInitializerTensors();
+
+  emscripten::val input = model_builder.GetOperand(input_defs[0]->Name());
+  emscripten::val scales = model_builder.GetOperand(input_defs[2]->Name());
+
+  std::vector<int64_t> B_shape;  // [N, n_blocks_per_col, blob_size]
+  ORT_RETURN_IF_NOT(GetShape(*input_defs[1], B_shape, logger), "Cannot get B shape");
+
+  NodeAttrHelper helper(node);
+  const uint32_t K = helper.Get("K", 0);
+  const uint32_t N = helper.Get("N", 0);
+  const uint32_t n_blocks_per_col = SafeInt<uint32_t>(B_shape[1]);
+  const uint32_t double_blob_size = SafeInt<uint32_t>(B_shape[2] * 2);
+
+  // Prepare DequantizeLinear's x input
+  // Input B is an initializer with data type 'uint8', we need to register it as 'uint4' WebNN constant
+  const std::vector<uint32_t> x_shape{N, n_blocks_per_col, double_blob_size};
+  emscripten::val x_shape_array = emscripten::val::array(x_shape);
+  emscripten::val x_desc = emscripten::val::object();
+  x_desc.set("dataType", emscripten::val("uint4"));
+  x_desc.set("shape", x_shape_array);
+  x_desc.set("dimensions", x_shape_array);
+  emscripten::val dq_x = emscripten::val::undefined();
+  const auto B_tensor = *initializers.at(input_defs[1]->Name());
+  ORT_RETURN_IF_ERROR(model_builder.RegisterConstant(B_tensor, dq_x, x_desc, logger));
+
+  // Prepare DequantizeLinear's x_scale input
+  // DequantizeLinear's x_scale should be [N, n_blocks_per_col, 1], reshape scales to [N, n_blocks_per_col, 1]
+  emscripten::val options = emscripten::val::object();
+  options.set("label", node.Name() + "_reshape_scales");
+  const std::vector<uint32_t> x_scale_shape{N, n_blocks_per_col, 1};
+  emscripten::val x_scale_shape_array = emscripten::val::array(x_scale_shape);
+  emscripten::val x_scale =
+      model_builder.GetBuilder().call<emscripten::val>("reshape", scales, x_scale_shape_array, options);
+
+  // Prepare DequantizeLinear's x_zero_point input
+  // x_zero_point has the same shape as x_scale
+  const bool has_zero_points = TensorExists(input_defs, 3);
+  emscripten::val x_zero_point = emscripten::val::undefined();
+  if (has_zero_points) {
+    // zero_points is an initializer with data type 'uint8', we need to register it as 'uint4' WebNN constant
+    const auto zero_points_tensor = *initializers.at(input_defs[3]->Name());
+    emscripten::val zero_points_desc = emscripten::val::object();
+    zero_points_desc.set("dataType", emscripten::val("uint4"));
+    zero_points_desc.set("shape", x_scale_shape_array);
+    zero_points_desc.set("dimensions", x_scale_shape_array);
+    ORT_RETURN_IF_ERROR(model_builder.RegisterConstant(zero_points_tensor, x_zero_point, zero_points_desc, logger));
+  } else {
+    // zero_points' default value is 8, referred from CPU EP
+    const int8_t default_zero_point = 8;
+    x_zero_point = model_builder.CreateOrGetConstant<int8_t>(ONNX_NAMESPACE::TensorProto_DataType_UINT4,
+                                                             default_zero_point,
+                                                             x_scale_shape);
+  }
+
+  // DequantizeLinear
+  options.set("label", node.Name() + "_dequantizeLinear");
+  emscripten::val dq =
+      model_builder.GetBuilder().call<emscripten::val>("dequantizeLinear", dq_x, x_scale, x_zero_point, options);
+
+  // Reshape DequantizeLinear to [N, K]
+  options.set("label", node.Name() + "_reshape_dequantizeLinear");
+  const std::vector<uint32_t> new_dq_shape{N, K};
+  emscripten::val new_dq_shape_array = emscripten::val::array(new_dq_shape);
+  emscripten::val dq_reshaped =
+      model_builder.GetBuilder().call<emscripten::val>("reshape", dq, new_dq_shape_array, options);
+
+  // Transpose reshaped DequantizeLinear to [K, N]
+  options.set("label", node.Name() + "_transpose_dequantizeLinear");
+  emscripten::val dq_transposed = model_builder.GetBuilder().call<emscripten::val>("transpose", dq_reshaped, options);
+
+  // MatMul
+  options.set("label", node.Name() + "_matmul");
+  emscripten::val output = model_builder.GetBuilder().call<emscripten::val>("matmul", input, dq_transposed, options);
+
+  // Add output with bias if present
+  if (TensorExists(input_defs, 5)) {
+    emscripten::val bias = model_builder.GetOperand(input_defs[5]->Name());
+    options.set("label", node.Name() + "_add_bias");
+    output = model_builder.GetBuilder().call<emscripten::val>("add", output, bias, options);
+  }
+
+  model_builder.AddOperand(output_defs[0]->Name(), std::move(output));
+
+  return Status::OK();
+}
+
+bool MatMulNBitsBuilder::IsOpSupportedImpl(const GraphViewer& graph_viewer,
+                                           const Node& node,
+                                           const WebnnDeviceType /* device_type */,
+                                           const logging::Logger& logger) const {
+  const auto& name = node.Name();
+  const auto& input_defs = node.InputDefs();
+  std::vector<int64_t> input_shape;
+  if (!GetShape(*input_defs[0], input_shape, logger)) {
+    return false;
+  }
+
+  // Inputs B and zero_points (if present) must be initializers
+  if (!graph_viewer.GetConstantInitializer(input_defs[1]->Name())) {  // B
+    LOGS(logger, VERBOSE) << "Input B of MatMulNBits [" << name << "] must be known as initializer";
+    return false;
+  }
+  if (TensorExists(input_defs, 3) && !graph_viewer.GetConstantInitializer(input_defs[3]->Name())) {  // zero_points
+    LOGS(logger, VERBOSE) << "Input zero_points of MatMulNBits [" << name << "] must be known as initializer";
+    return false;
+  }
+
+  // WebNN doesn't support g_idx input
+  if (TensorExists(input_defs, 4)) {  // g_idx
+    LOGS(logger, VERBOSE) << "Input g_idx of MatMulNBits [" << name << "] is not supported";
+    return false;
+  }
+
+  NodeAttrHelper helper(node);
+  if (helper.Get("bits", 4) != 4) {
+    LOGS(logger, VERBOSE) << "Only 4-bit quantization is supported for MatMulNBits, additional bits support is planned";
+  }
+
+  return true;
+}
+
+bool MatMulNBitsBuilder::HasSupportedInputsImpl(const GraphViewer&,
+                                                const Node& node, const emscripten::val& wnn_limits,
+                                                const logging::Logger& logger) const {
+  const auto& input_defs = node.InputDefs();
+  const std::string_view op_type = node.OpType();
+
+  int32_t A_type = 0;
+  int32_t B_type = 0;
+  int32_t scales_type = 0;
+  int32_t zero_points_type = 0;
+  if (!GetType(*input_defs[0], A_type, logger) ||
+      !GetType(*input_defs[1], B_type, logger) ||
+      !GetType(*input_defs[2], scales_type, logger)) {
+    return false;
+  }
+
+  const bool has_zero_points = TensorExists(input_defs, 3);
+  if (has_zero_points && !GetType(*input_defs[3], zero_points_type, logger)) {
+    return false;
+  }
+
+  InlinedVector<int32_t, 2> input_types = {A_type, scales_type};
+  if (!AreInputDataTypesSame(op_type, input_types, logger)) {
+    return false;
+  }
+
+  if (A_type != ONNX_NAMESPACE::TensorProto_DataType_FLOAT && A_type != ONNX_NAMESPACE::TensorProto_DataType_FLOAT16) {
+    LOGS(logger, VERBOSE) << "WebNN only supports float32 or float16 data type for input A of MatMulNBits";
+    return false;
+  }
+  if (B_type != ONNX_NAMESPACE::TensorProto_DataType_UINT8) {
+    LOGS(logger, VERBOSE) << "WebNN only supports uint8 data type for input B of MatMulNBits";
+    return false;
+  }
+  if (has_zero_points && zero_points_type != ONNX_NAMESPACE::TensorProto_DataType_UINT8) {
+    LOGS(logger, VERBOSE) << "WebNN only supports uint8 data type for input zero_points of MatMulNBits";
+    return false;
+  }
+
+  // We only support 4-bit quantization, which is represented as the uint4 data type in WebNN.
+  // Ensure that uint4 is supported.
+  return IsDataTypeSupportedByOp("DequantizeLinear", ONNX_NAMESPACE::TensorProto_DataType_UINT4,
+                                 wnn_limits, "input", "x", logger);
+}
+
+bool MatMulNBitsBuilder::HasSupportedOutputsImpl(const Node& node, const emscripten::val& wnn_limits,
+                                                 const logging::Logger& logger) const {
+  const auto& output_defs = node.OutputDefs();
+
+  int32_t output_type = 0;
+  if (!GetType(*output_defs[0], output_type, logger)) {
+    return false;
+  }
+
+  if (output_type != ONNX_NAMESPACE::TensorProto_DataType_FLOAT &&
+      output_type != ONNX_NAMESPACE::TensorProto_DataType_FLOAT16) {
+    LOGS(logger, VERBOSE) << "WebNN only supports float32 or float16 data type for output of MatMulNBits";
+    return false;
+  }
+
+  return true;
+}
+
+void CreateMatMulNBitsOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations) {
+  op_registrations.builders.push_back(std::make_unique<MatMulNBitsBuilder>());
+  op_registrations.op_builder_map.emplace(op_type, op_registrations.builders.back().get());
+}
+
+}  // namespace webnn
+}  // namespace onnxruntime
diff --git a/onnxruntime/core/providers/webnn/builders/model_builder.cc b/onnxruntime/core/providers/webnn/builders/model_builder.cc
index 399cc5faf6273..e42a33e858a07 100644
--- a/onnxruntime/core/providers/webnn/builders/model_builder.cc
+++ b/onnxruntime/core/providers/webnn/builders/model_builder.cc
@@ -95,6 +95,114 @@ void ModelBuilder::PreprocessInitializers() {
   }
 }
 
+Status ModelBuilder::RegisterConstant(const onnx::TensorProto& tensor, emscripten::val& operand,
+                                      emscripten::val& desc, const logging::Logger& logger) {
+  emscripten::val wnn_builder = GetBuilder();
+  const auto data_type = tensor.data_type();
+
+  // A flag to indicate if we should convert int64 to int32.
+  const bool should_convert_int64_to_int32 = !IsInt64Supported() &&
+                                             data_type == ONNX_NAMESPACE::TensorProto_DataType_INT64;
+
+  if (utils::HasExternalData(tensor)) {
+    // Create WebNN Constant from external data.
+    std::basic_string<ORTCHAR_T> external_file_path;
+    onnxruntime::FileOffsetType data_offset;
+    SafeInt<size_t> tensor_byte_size;
+    ORT_RETURN_IF_ERROR(utils::GetExternalDataInfo(
+        tensor, graph_viewer_.ModelPath(), external_file_path, data_offset, tensor_byte_size));
+
+    auto webnnRegisterMLConstant = emscripten::val::module_property("webnnRegisterMLConstant");
+    operand = webnnRegisterMLConstant(emscripten::val(external_file_path),
+                                      static_cast<int32_t>(data_offset),
+                                      static_cast<int32_t>(tensor_byte_size),
+                                      wnn_builder,
+                                      desc,
+                                      should_convert_int64_to_int32);
+  } else {
+    std::byte* tensor_ptr = nullptr;
+    std::vector<uint8_t> unpacked_tensor;
+    emscripten::val view = emscripten::val::undefined();
+
+    if (tensor.has_raw_data()) {
+      tensor_ptr = reinterpret_cast<std::byte*>(const_cast<char*>(tensor.raw_data().c_str()));
+    } else {
+      ORT_RETURN_IF_ERROR(onnxruntime::utils::UnpackInitializerData(tensor, unpacked_tensor));
+      tensor_ptr = reinterpret_cast<std::byte*>(unpacked_tensor.data());
+    }
+
+    const auto& shape = tensor.dims();
+    auto num_elements = SafeInt<size_t>(Product(shape));
+    if (data_type == ONNX_NAMESPACE::TensorProto_DataType_INT4 ||
+        data_type == ONNX_NAMESPACE::TensorProto_DataType_UINT4) {
+      // For WebNN int4 and uint4 tensors are stored in Uint8Array,
+      // so we need to adjust the number of elements.
+      num_elements = (static_cast<size_t>(num_elements) + 1) / 2;
+    }
+    switch (data_type) {
+      case ONNX_NAMESPACE::TensorProto_DataType_BOOL:
+      case ONNX_NAMESPACE::TensorProto_DataType_INT4:
+      case ONNX_NAMESPACE::TensorProto_DataType_UINT4:
+      case ONNX_NAMESPACE::TensorProto_DataType_UINT8:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<uint8_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_INT8:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<int8_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_FLOAT16:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<uint16_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_FLOAT:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<float*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_INT32:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<int32_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_INT64:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<int64_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_UINT32:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<uint32_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_UINT64:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<uint64_t*>(tensor_ptr))};
+        break;
+      default:
+        break;
+    }
+
+    // If int64 is not supported, convert int64 to int32.
+    std::vector<int32_t> int32_data;
+    if (should_convert_int64_to_int32) {
+      try {
+        int32_data = GetNarrowedIntfromInt64<int32_t>(
+            gsl::span<const int64_t>(reinterpret_cast<int64_t*>(tensor_ptr), num_elements));
+        LOGS(logger, VERBOSE) << "Initializer '" << tensor.name() << "' is converted from int64 to int32.";
+      } catch (const std::exception& e) {
+        return ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT, e.what());
+      }
+      view = emscripten::val{emscripten::typed_memory_view(num_elements, int32_data.data())};
+
+      desc.set("dataType", emscripten::val("int32"));
+    }
+
+    // Wasm memory growth will cause all array buffers reallocation, which will be treated as detached
+    // buffers in JS side. Simply create a copy to fix it.
+    view = view.call<emscripten::val>("slice");
+    operand = wnn_builder.call<emscripten::val>("constant", desc, view["buffer"]);
+  }
+
+  return Status::OK();
+}
+
 Status ModelBuilder::RegisterInitializers() {
   for (const auto& pair : GetInitializerTensors()) {
     const auto& tensor = *pair.second;
@@ -119,109 +227,11 @@ Status ModelBuilder::RegisterInitializers() {
     // in WebNN EP for a while to support older Chromium versions.
     desc.set("dimensions", emscripten::val::array(dims));
     desc.set("shape", emscripten::val::array(dims));
-    auto data_type = tensor.data_type();
+    const auto data_type = tensor.data_type();
     emscripten::val operand = emscripten::val::object();
     if (IsSupportedDataType(data_type, wnn_limits_["constant"]["dataTypes"])) {
       ORT_RETURN_IF_NOT(SetWebnnDataType(desc, data_type), "Unsupported data type");
-      auto num_elements = SafeInt<size_t>(Product(shape));
-      emscripten::val view = emscripten::val::undefined();
-      std::byte* tensor_ptr = nullptr;
-
-      // A flag to indicate if we should convert int64 to int32.
-      const bool should_convert_int64_to_int32 = !is_int64_supported_ &&
-                                                 data_type == ONNX_NAMESPACE::TensorProto_DataType_INT64;
-
-      if (utils::HasExternalData(tensor)) {
-        // Create WebNN Constant from external data.
-        std::basic_string<ORTCHAR_T> external_file_path;
-        onnxruntime::FileOffsetType data_offset;
-        SafeInt<size_t> tensor_byte_size;
-        ORT_RETURN_IF_ERROR(utils::GetExternalDataInfo(
-            tensor, graph_viewer_.ModelPath(), external_file_path, data_offset, tensor_byte_size));
-
-        auto webnnRegisterMLConstant = emscripten::val::module_property("webnnRegisterMLConstant");
-        operand = webnnRegisterMLConstant(emscripten::val(external_file_path),
-                                          static_cast<int32_t>(data_offset),
-                                          static_cast<int32_t>(tensor_byte_size),
-                                          wnn_builder_,
-                                          desc,
-                                          should_convert_int64_to_int32);
-      } else {
-        if (tensor.has_raw_data()) {
-          tensor_ptr = reinterpret_cast<std::byte*>(const_cast<char*>(tensor.raw_data().c_str()));
-        } else {
-          // Store temporary unpacked_tensor.
-          unpacked_tensors_.push_back({});
-          std::vector<uint8_t>& unpacked_tensor = unpacked_tensors_.back();
-          ORT_RETURN_IF_ERROR(onnxruntime::utils::UnpackInitializerData(tensor, unpacked_tensor));
-          tensor_ptr = reinterpret_cast<std::byte*>(unpacked_tensor.data());
-        }
-        if (data_type == ONNX_NAMESPACE::TensorProto_DataType_INT4 ||
-            data_type == ONNX_NAMESPACE::TensorProto_DataType_UINT4) {
-          // For WebNN int4 and uint4 tensors are stored in Uint8Array,
-          // so we need to adjust the number of elements.
-          num_elements = (static_cast<size_t>(num_elements) + 1) / 2;
-        }
-        switch (data_type) {
-          case ONNX_NAMESPACE::TensorProto_DataType_BOOL:
-          case ONNX_NAMESPACE::TensorProto_DataType_INT4:
-          case ONNX_NAMESPACE::TensorProto_DataType_UINT4:
-          case ONNX_NAMESPACE::TensorProto_DataType_UINT8:
-            view = emscripten::val{emscripten::typed_memory_view(num_elements,
-                                                                 reinterpret_cast<uint8_t*>(tensor_ptr))};
-            break;
-          case ONNX_NAMESPACE::TensorProto_DataType_INT8:
-            view = emscripten::val{emscripten::typed_memory_view(num_elements,
-                                                                 reinterpret_cast<int8_t*>(tensor_ptr))};
-            break;
-          case ONNX_NAMESPACE::TensorProto_DataType_FLOAT16:
-            view = emscripten::val{emscripten::typed_memory_view(num_elements,
-                                                                 reinterpret_cast<uint16_t*>(tensor_ptr))};
-            break;
-          case ONNX_NAMESPACE::TensorProto_DataType_FLOAT:
-            view = emscripten::val{emscripten::typed_memory_view(num_elements,
-                                                                 reinterpret_cast<float*>(tensor_ptr))};
-            break;
-          case ONNX_NAMESPACE::TensorProto_DataType_INT32:
-            view = emscripten::val{emscripten::typed_memory_view(num_elements,
-                                                                 reinterpret_cast<int32_t*>(tensor_ptr))};
-            break;
-          case ONNX_NAMESPACE::TensorProto_DataType_INT64:
-            view = emscripten::val{emscripten::typed_memory_view(num_elements,
-                                                                 reinterpret_cast<int64_t*>(tensor_ptr))};
-            break;
-          case ONNX_NAMESPACE::TensorProto_DataType_UINT32:
-            view = emscripten::val{emscripten::typed_memory_view(num_elements,
-                                                                 reinterpret_cast<uint32_t*>(tensor_ptr))};
-            break;
-          case ONNX_NAMESPACE::TensorProto_DataType_UINT64:
-            view = emscripten::val{emscripten::typed_memory_view(num_elements,
-                                                                 reinterpret_cast<uint64_t*>(tensor_ptr))};
-            break;
-          default:
-            break;
-        }
-
-        // If int64 is not supported, convert int64 to int32.
-        std::vector<int32_t> int32_data;
-        if (should_convert_int64_to_int32) {
-          try {
-            int32_data = GetNarrowedIntfromInt64<int32_t>(
-                gsl::span<const int64_t>(reinterpret_cast<int64_t*>(tensor_ptr), num_elements));
-            LOGS(logger_, VERBOSE) << "Initializer '" << name << "' is converted from int64 to int32.";
-          } catch (const std::exception& e) {
-            return ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT, e.what());
-          }
-          view = emscripten::val{emscripten::typed_memory_view(num_elements, int32_data.data())};
-
-          desc.set("dataType", emscripten::val("int32"));
-        }
-
-        // Wasm memory grow will cause all array buffers reallocation, which will be treated as detached
-        // buffers in JS side. Simply create a copy to fix it.
-        view = view.call<emscripten::val>("slice");
-        operand = wnn_builder_.call<emscripten::val>("constant", desc, view["buffer"]);
-      }
+      ORT_RETURN_IF_ERROR(RegisterConstant(tensor, operand, desc, logger_));
     } else {
       // TODO: support other type.
       return ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT,
@@ -375,7 +385,7 @@ Status ModelBuilder::AddOperandFromPersistMemoryBuffer(
   desc.set("dimensions", emscripten::val::array(shape));
   desc.set("shape", emscripten::val::array(shape));
   emscripten::val operand = emscripten::val::object();
-  // Wasm memory grow will cause all array buffers reallocation, which will be treated as detached
+  // Wasm memory growth will cause all array buffers reallocation, which will be treated as detached
   // buffers in JS side. Simply create a copy to fix it.
   view = view.call<emscripten::val>("slice");
   operand = wnn_builder_.call<emscripten::val>("constant", desc, view["buffer"]);
diff --git a/onnxruntime/core/providers/webnn/builders/model_builder.h b/onnxruntime/core/providers/webnn/builders/model_builder.h
index f45e38935651f..da53a39f1b109 100644
--- a/onnxruntime/core/providers/webnn/builders/model_builder.h
+++ b/onnxruntime/core/providers/webnn/builders/model_builder.h
@@ -43,6 +43,9 @@ class ModelBuilder {
 
   void AddOperand(const std::string& name, const emscripten::val& operand);
 
+  // Register a WebNN constant operand using the provided tensor and descriptor information.
+  Status RegisterConstant(const onnx::TensorProto& tensor, emscripten::val& operand,
+                          emscripten::val& desc, const logging::Logger& logger);
   template <typename T>
   const emscripten::val& CreateOrGetConstant(const int32_t& data_type, T value,
                                              const std::vector<uint32_t>& shape = {});
@@ -83,7 +86,6 @@ class ModelBuilder {
   InlinedHashMap<std::string, emscripten::val> wnn_operands_;
   std::vector<std::string> input_names_;
   std::vector<std::string> output_names_;
-  std::vector<std::vector<uint8_t>> unpacked_tensors_;
 
   InlinedHashMap<std::string, OnnxTensorInfo> input_output_info_;
 
@@ -161,7 +163,7 @@ const emscripten::val& ModelBuilder::CreateOrGetConstant(const int32_t& data_typ
         num_elements = (num_elements + 1) / 2;
         buffer = emscripten::val::global("Uint8Array").new_(num_elements);
         if (value) {
-          buffer.call<void>("fill", emscripten::val(PackInt8ToUint8AsNibble(value, data_type)));
+          buffer.call<void>("fill", emscripten::val(PackInt8ToUint8DoubledNibbles(value, data_type)));
         }
         break;
       case ONNX_NAMESPACE::TensorProto_DataType_BOOL:
diff --git a/onnxruntime/core/providers/webnn/builders/op_builder_factory.cc b/onnxruntime/core/providers/webnn/builders/op_builder_factory.cc
index ee21a33091078..85525e0f5f327 100644
--- a/onnxruntime/core/providers/webnn/builders/op_builder_factory.cc
+++ b/onnxruntime/core/providers/webnn/builders/op_builder_factory.cc
@@ -149,6 +149,10 @@ static OpBuilderRegistrations CreateOpBuilderRegistrations() {
     CreateLstmOpBuilder("LSTM", op_registrations);
   }
 
+  {  // MatMulNBits
+    CreateMatMulNBitsOpBuilder("MatMulNBits", op_registrations);
+  }
+
   {  // Max/Min
     CreateMaxMinOpBuilder("Max", op_registrations);
     CreateMaxMinOpBuilder("Min", op_registrations);
diff --git a/onnxruntime/core/providers/webnn/builders/op_builder_factory.h b/onnxruntime/core/providers/webnn/builders/op_builder_factory.h
index 1c4a7b32f842b..8c120627d65c5 100644
--- a/onnxruntime/core/providers/webnn/builders/op_builder_factory.h
+++ b/onnxruntime/core/providers/webnn/builders/op_builder_factory.h
@@ -40,6 +40,7 @@ void CreateGruOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_r
 void CreateLogicalOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations);
 void CreateLRNOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations);
 void CreateLstmOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations);
+void CreateMatMulNBitsOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations);
 void CreateMaxMinOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations);
 void CreateNormalizationOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations);
 void CreatePadOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations);


Reviews:
user: login: fdwr
body: Few comments, else LGTM.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T05:34:27Z
user: login: Honry
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T06:59:19Z
user: login: fdwr
body: 👍
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T01:24:47Z

Review comments:
diff_hunk: @@ -274,20 +274,20 @@ bool IsMLTensorSupported() {
 
 // Convert int8 to uint4/int4 (stored as uint8)
 uint8_t PackInt8ToUint8AsNibble(int8_t value, const int32_t& data_type) {
path: onnxruntime/core/providers/webnn/builders/helper.cc
user: login: fdwr
body: This name could be more obvious what it's doing, because it's actually packing 2 nibbles per output (not Nibble singular).

`PackInt8ToUint8TwoNibbles`
`PackInt8ToUint8DoubledNibbles`

Also, why duplication? And when is bitwise truncation valid rather than clamping?
created_at: 2025-04-07T05:01:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Intel Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#include "core/providers/shared/utils/utils.h"
+#include "core/providers/webnn/builders/helper.h"
+#include "core/providers/webnn/builders/model_builder.h"
+#include "core/providers/webnn/builders/op_builder_factory.h"
+
+#include "base_op_builder.h"
+
+namespace onnxruntime {
+namespace webnn {
+
+class MatMulNBitsBuilder : public BaseOpBuilder {
+  // Add operator related.
+ public:
+  void AddInitializersToSkip(ModelBuilder& model_builder, const Node& node) const override;
+
+ private:
+  Status AddToModelBuilderImpl(ModelBuilder& model_builder, const Node& node,
+                               const logging::Logger& logger) const override ORT_MUST_USE_RESULT;
+
+  // Operator support related.
+ private:
+  bool IsOpSupportedImpl(const GraphViewer& graph_viewer, const Node& node,
+                         const WebnnDeviceType /* device_type */, const logging::Logger& logger) const override;
+  bool HasSupportedInputsImpl(const GraphViewer&, const Node& node,
+                              const emscripten::val& wnn_limits, const logging::Logger& logger) const override;
+  bool HasSupportedOutputsImpl(const Node& node, const emscripten::val& wnn_limits,
+                               const logging::Logger& logger) const override;
+};
+
+void MatMulNBitsBuilder::AddInitializersToSkip(ModelBuilder& model_builder, const Node& node) const {
+  // Inputs B and zero_points (if present) must be initializers. If they are of type uint8,
+  // they should be stored as uint4 constants in WebNN. Therefore, we skip them here and
+  // delay their registration as WebNN constants.
+  const auto& input_defs = node.InputDefs();
+  if (input_defs[1]->TypeAsProto()->tensor_type().elem_type() == ONNX_NAMESPACE::TensorProto_DataType_UINT8) {
+    model_builder.AddInitializerToSkip(input_defs[1]->Name());  // B
+    if (TensorExists(input_defs, 3)) {
+      model_builder.AddInitializerToSkip(input_defs[3]->Name());  // zero_points
+    }
+  }
+}
+
+// WebNN doesn't provide a dedicated op for MatMulNBits, it can be simply decomposed by
+// DequantizeLinear + Transpose + MatMul. Given that the CPU EP currently only supports
+// 4-bit quantization, we only handle 4-bit quantization here.
+//
+// To align with WebNN's dequantizeLinear op contraints, the following transformations are
+// required for MatMulNBits inputs:
+// 1. B: must be a constant initializer and registered as a 'uint4' WebNN constant with shape
+//       [N, n_blocks_per_col, blob_size * 2].
+// 2. scales: reshape it to [N, n_blocks_per_col, 1].
+// 3. zero_points: it has the same shape as reshaped scales. If it presents, it must be a
+//                 constant initializer and registered as a 'uint4' WebNN constant.
+//                 Otherwise, it must be registered as a 'uint4' WebNN constant with default value 8.
+Status MatMulNBitsBuilder::AddToModelBuilderImpl(ModelBuilder& model_builder,
+                                                 const Node& node,
+                                                 const logging::Logger& logger) const {
+  const auto& input_defs = node.InputDefs();
+  const auto& output_defs = node.OutputDefs();
+  const auto& initializers = model_builder.GetInitializerTensors();
+
+  emscripten::val input = model_builder.GetOperand(input_defs[0]->Name());
+  emscripten::val scales = model_builder.GetOperand(input_defs[2]->Name());
+
+  std::vector<int64_t> B_shape;  // [N, n_blocks_per_col, blob_size]
+  ORT_RETURN_IF_NOT(GetShape(*input_defs[1], B_shape, logger), "Cannot get B shape");
+
+  NodeAttrHelper helper(node);
+  const uint32_t K = helper.Get("K", 0);
+  const uint32_t N = helper.Get("N", 0);
+  const uint32_t n_blocks_per_col = SafeInt<uint32_t>(B_shape[1]);
+  const uint32_t double_blob_size = SafeInt<uint32_t>(B_shape[2] * 2);
+
+  // Prepare DequantizeLinear's x input
+  // Input B is an initializer with data type 'uint8', we need to register it as 'uint4' WebNN constant
+  const std::vector<uint32_t> x_shape{N, n_blocks_per_col, double_blob_size};
+  emscripten::val x_shape_array = emscripten::val::array(x_shape);
+  emscripten::val x_desc = emscripten::val::object();
+  x_desc.set("dataType", emscripten::val("uint4"));
+  x_desc.set("shape", x_shape_array);
+  x_desc.set("dimensions", x_shape_array);
+  emscripten::val dq_x = emscripten::val::undefined();
+  const auto B_tensor = *initializers.at(input_defs[1]->Name());
+  ORT_RETURN_IF_ERROR(model_builder.RegisterConstant(B_tensor, dq_x, x_desc, logger));
+
+  // Prepare DequantizeLinear's x_scale input
+  // DequantizeLinear's x_scale should be [N, n_blocks_per_col, 1], reshape scales to [N, n_blocks_per_col, 1]
+  emscripten::val options = emscripten::val::object();
+  options.set("label", node.Name() + "_reshape_scales");
+  const std::vector<uint32_t> x_scale_shape{N, n_blocks_per_col, 1};
+  emscripten::val x_scale_shape_array = emscripten::val::array(x_scale_shape);
+  emscripten::val x_scale =
+      model_builder.GetBuilder().call<emscripten::val>("reshape", scales, x_scale_shape_array, options);
+
+  // Prepare DequantizeLinear's x_zero_point input
+  // x_zero_point has the same shape as x_scale
+  const bool has_zero_points = TensorExists(input_defs, 3);
+  emscripten::val x_zero_point = emscripten::val::undefined();
+  if (has_zero_points) {
+    // zero_points is an initializer with data type 'uint8', we need to register it as 'uint4' WebNN constant
+    const auto zero_points_tensor = *initializers.at(input_defs[3]->Name());
+    emscripten::val zero_points_desc = emscripten::val::object();
+    zero_points_desc.set("dataType", emscripten::val("uint4"));
+    zero_points_desc.set("shape", x_scale_shape_array);
+    zero_points_desc.set("dimensions", x_scale_shape_array);
+    ORT_RETURN_IF_ERROR(model_builder.RegisterConstant(zero_points_tensor, x_zero_point, zero_points_desc, logger));
+  } else {
+    // zero_points' default value is 8, referred from CPU EP
+    const int8_t default_zero_point = 8;
+    x_zero_point = model_builder.CreateOrGetConstant<int8_t>(ONNX_NAMESPACE::TensorProto_DataType_UINT4,
+                                                             default_zero_point,
+                                                             x_scale_shape);
+  }
+
+  // DequantizeLinear
+  options.set("label", node.Name() + "_dequantizeLinear");
+  emscripten::val dq =
+      model_builder.GetBuilder().call<emscripten::val>("dequantizeLinear", dq_x, x_scale, x_zero_point, options);
+
+  // Reshape DequantizeLinear to [N, K]
+  options.set("label", node.Name() + "_reshape_dequantizeLinear");
+  const std::vector<uint32_t> new_dq_shape{N, K};
+  emscripten::val new_dq_shape_array = emscripten::val::array(new_dq_shape);
+  emscripten::val dq_reshaped =
+      model_builder.GetBuilder().call<emscripten::val>("reshape", dq, new_dq_shape_array, options);
+
+  // Transpose reshaped DequantizedLinear to [K, N]
path: onnxruntime/core/providers/webnn/builders/impl/matMulNBits_op_builder.cc
user: login: fdwr
body: ```suggestion
  // Transpose reshaped DequantizeLinear to [K, N]
```
created_at: 2025-04-07T05:12:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Intel Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#include "core/providers/shared/utils/utils.h"
+#include "core/providers/webnn/builders/helper.h"
+#include "core/providers/webnn/builders/model_builder.h"
+#include "core/providers/webnn/builders/op_builder_factory.h"
+
+#include "base_op_builder.h"
+
+namespace onnxruntime {
+namespace webnn {
+
+class MatMulNBitsBuilder : public BaseOpBuilder {
+  // Add operator related.
+ public:
+  void AddInitializersToSkip(ModelBuilder& model_builder, const Node& node) const override;
+
+ private:
+  Status AddToModelBuilderImpl(ModelBuilder& model_builder, const Node& node,
+                               const logging::Logger& logger) const override ORT_MUST_USE_RESULT;
+
+  // Operator support related.
+ private:
+  bool IsOpSupportedImpl(const GraphViewer& graph_viewer, const Node& node,
+                         const WebnnDeviceType /* device_type */, const logging::Logger& logger) const override;
+  bool HasSupportedInputsImpl(const GraphViewer&, const Node& node,
+                              const emscripten::val& wnn_limits, const logging::Logger& logger) const override;
+  bool HasSupportedOutputsImpl(const Node& node, const emscripten::val& wnn_limits,
+                               const logging::Logger& logger) const override;
+};
+
+void MatMulNBitsBuilder::AddInitializersToSkip(ModelBuilder& model_builder, const Node& node) const {
+  // Inputs B and zero_points (if present) must be initializers. If they are of type uint8,
+  // they should be stored as uint4 constants in WebNN. Therefore, we skip them here and
+  // delay their registration as WebNN constants.
+  const auto& input_defs = node.InputDefs();
+  if (input_defs[1]->TypeAsProto()->tensor_type().elem_type() == ONNX_NAMESPACE::TensorProto_DataType_UINT8) {
+    model_builder.AddInitializerToSkip(input_defs[1]->Name());  // B
+    if (TensorExists(input_defs, 3)) {
+      model_builder.AddInitializerToSkip(input_defs[3]->Name());  // zero_points
+    }
+  }
+}
+
+// WebNN doesn't provide a dedicated op for MatMulNBits, it can be simply decomposed by
+// DequantizeLinear + Transpose + MatMul. Given that the CPU EP currently only supports
+// 4-bit quantization, we only handle 4-bit quantization here.
+//
+// To align with WebNN's dequantizeLinear op contraints, the following transformations are
+// required for MatMulNBits inputs:
+// 1. B: must be a constant initializer and registered as a 'uint4' WebNN constant with shape
+//       [N, n_blocks_per_col, blob_size * 2].
+// 2. scales: reshape it to [N, n_blocks_per_col, 1].
+// 3. zero_points: it has the same shape as reshaped scales. If it presents, it must be a
+//                 constant initializer and registered as a 'uint4' WebNN constant.
+//                 Otherwise, it must be registered as a 'uint4' WebNN constant with default value 8.
+Status MatMulNBitsBuilder::AddToModelBuilderImpl(ModelBuilder& model_builder,
+                                                 const Node& node,
+                                                 const logging::Logger& logger) const {
+  const auto& input_defs = node.InputDefs();
+  const auto& output_defs = node.OutputDefs();
+  const auto& initializers = model_builder.GetInitializerTensors();
+
+  emscripten::val input = model_builder.GetOperand(input_defs[0]->Name());
+  emscripten::val scales = model_builder.GetOperand(input_defs[2]->Name());
+
+  std::vector<int64_t> B_shape;  // [N, n_blocks_per_col, blob_size]
+  ORT_RETURN_IF_NOT(GetShape(*input_defs[1], B_shape, logger), "Cannot get B shape");
+
+  NodeAttrHelper helper(node);
+  const uint32_t K = helper.Get("K", 0);
+  const uint32_t N = helper.Get("N", 0);
+  const uint32_t n_blocks_per_col = SafeInt<uint32_t>(B_shape[1]);
+  const uint32_t double_blob_size = SafeInt<uint32_t>(B_shape[2] * 2);
+
+  // Prepare DequantizeLinear's x input
+  // Input B is an initializer with data type 'uint8', we need to register it as 'uint4' WebNN constant
+  const std::vector<uint32_t> x_shape{N, n_blocks_per_col, double_blob_size};
+  emscripten::val x_shape_array = emscripten::val::array(x_shape);
+  emscripten::val x_desc = emscripten::val::object();
+  x_desc.set("dataType", emscripten::val("uint4"));
+  x_desc.set("shape", x_shape_array);
+  x_desc.set("dimensions", x_shape_array);
+  emscripten::val dq_x = emscripten::val::undefined();
+  const auto B_tensor = *initializers.at(input_defs[1]->Name());
+  ORT_RETURN_IF_ERROR(model_builder.RegisterConstant(B_tensor, dq_x, x_desc, logger));
+
+  // Prepare DequantizeLinear's x_scale input
+  // DequantizeLinear's x_scale should be [N, n_blocks_per_col, 1], reshape scales to [N, n_blocks_per_col, 1]
+  emscripten::val options = emscripten::val::object();
+  options.set("label", node.Name() + "_reshape_scales");
+  const std::vector<uint32_t> x_scale_shape{N, n_blocks_per_col, 1};
+  emscripten::val x_scale_shape_array = emscripten::val::array(x_scale_shape);
+  emscripten::val x_scale =
+      model_builder.GetBuilder().call<emscripten::val>("reshape", scales, x_scale_shape_array, options);
+
+  // Prepare DequantizeLinear's x_zero_point input
+  // x_zero_point has the same shape as x_scale
+  const bool has_zero_points = TensorExists(input_defs, 3);
+  emscripten::val x_zero_point = emscripten::val::undefined();
+  if (has_zero_points) {
+    // zero_points is an initializer with data type 'uint8', we need to register it as 'uint4' WebNN constant
+    const auto zero_points_tensor = *initializers.at(input_defs[3]->Name());
+    emscripten::val zero_points_desc = emscripten::val::object();
+    zero_points_desc.set("dataType", emscripten::val("uint4"));
+    zero_points_desc.set("shape", x_scale_shape_array);
+    zero_points_desc.set("dimensions", x_scale_shape_array);
+    ORT_RETURN_IF_ERROR(model_builder.RegisterConstant(zero_points_tensor, x_zero_point, zero_points_desc, logger));
+  } else {
+    // zero_points' default value is 8, referred from CPU EP
+    const int8_t default_zero_point = 8;
+    x_zero_point = model_builder.CreateOrGetConstant<int8_t>(ONNX_NAMESPACE::TensorProto_DataType_UINT4,
+                                                             default_zero_point,
+                                                             x_scale_shape);
+  }
+
+  // DequantizeLinear
+  options.set("label", node.Name() + "_dequantizeLinear");
+  emscripten::val dq =
+      model_builder.GetBuilder().call<emscripten::val>("dequantizeLinear", dq_x, x_scale, x_zero_point, options);
+
+  // Reshape DequantizeLinear to [N, K]
+  options.set("label", node.Name() + "_reshape_dequantizeLinear");
+  const std::vector<uint32_t> new_dq_shape{N, K};
+  emscripten::val new_dq_shape_array = emscripten::val::array(new_dq_shape);
+  emscripten::val dq_reshaped =
+      model_builder.GetBuilder().call<emscripten::val>("reshape", dq, new_dq_shape_array, options);
+
+  // Transpose reshaped DequantizedLinear to [K, N]
+  options.set("label", node.Name() + "_transpose_dequantizeLinear");
+  emscripten::val dq_transposed = model_builder.GetBuilder().call<emscripten::val>("transpose", dq_reshaped, options);
+
+  // MatMul
+  options.set("label", node.Name() + "_matmul");
+  emscripten::val output = model_builder.GetBuilder().call<emscripten::val>("matmul", input, dq_transposed, options);
+
+  // Add output with bias if it presents
path: onnxruntime/core/providers/webnn/builders/impl/matMulNBits_op_builder.cc
user: login: fdwr
body: ```suggestion
  // Add output with bias if present
```
created_at: 2025-04-07T05:12:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,6 +95,114 @@ void ModelBuilder::PreprocessInitializers() {
   }
 }
 
+Status ModelBuilder::RegisterConstant(const onnx::TensorProto& tensor, emscripten::val& operand,
+                                      emscripten::val& desc, const logging::Logger& logger) {
+  emscripten::val wnn_builder = GetBuilder();
+  const auto data_type = tensor.data_type();
+
+  // A flag to indicate if we should convert int64 to int32.
+  const bool should_convert_int64_to_int32 = !IsInt64Supported() &&
+                                             data_type == ONNX_NAMESPACE::TensorProto_DataType_INT64;
+
+  if (utils::HasExternalData(tensor)) {
+    // Create WebNN Constant from external data.
+    std::basic_string<ORTCHAR_T> external_file_path;
+    onnxruntime::FileOffsetType data_offset;
+    SafeInt<size_t> tensor_byte_size;
+    ORT_RETURN_IF_ERROR(utils::GetExternalDataInfo(
+        tensor, graph_viewer_.ModelPath(), external_file_path, data_offset, tensor_byte_size));
+
+    auto webnnRegisterMLConstant = emscripten::val::module_property("webnnRegisterMLConstant");
+    operand = webnnRegisterMLConstant(emscripten::val(external_file_path),
+                                      static_cast<int32_t>(data_offset),
+                                      static_cast<int32_t>(tensor_byte_size),
+                                      wnn_builder,
+                                      desc,
+                                      should_convert_int64_to_int32);
+  } else {
+    std::byte* tensor_ptr = nullptr;
+    std::vector<uint8_t> unpacked_tensor;
+    emscripten::val view = emscripten::val::undefined();
+
+    if (tensor.has_raw_data()) {
+      tensor_ptr = reinterpret_cast<std::byte*>(const_cast<char*>(tensor.raw_data().c_str()));
+    } else {
+      ORT_RETURN_IF_ERROR(onnxruntime::utils::UnpackInitializerData(tensor, unpacked_tensor));
+      tensor_ptr = reinterpret_cast<std::byte*>(unpacked_tensor.data());
+    }
+
+    const auto& shape = tensor.dims();
+    auto num_elements = SafeInt<size_t>(Product(shape));
+    if (data_type == ONNX_NAMESPACE::TensorProto_DataType_INT4 ||
+        data_type == ONNX_NAMESPACE::TensorProto_DataType_UINT4) {
+      // For WebNN int4 and uint4 tensors are stored in Uint8Array,
+      // so we need to adjust the number of elements.
+      num_elements = (static_cast<size_t>(num_elements) + 1) / 2;
+    }
+    switch (data_type) {
+      case ONNX_NAMESPACE::TensorProto_DataType_BOOL:
+      case ONNX_NAMESPACE::TensorProto_DataType_INT4:
+      case ONNX_NAMESPACE::TensorProto_DataType_UINT4:
+      case ONNX_NAMESPACE::TensorProto_DataType_UINT8:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<uint8_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_INT8:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<int8_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_FLOAT16:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<uint16_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_FLOAT:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<float*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_INT32:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<int32_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_INT64:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<int64_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_UINT32:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<uint32_t*>(tensor_ptr))};
+        break;
+      case ONNX_NAMESPACE::TensorProto_DataType_UINT64:
+        view = emscripten::val{emscripten::typed_memory_view(num_elements,
+                                                             reinterpret_cast<uint64_t*>(tensor_ptr))};
+        break;
+      default:
+        break;
+    }
+
+    // If int64 is not supported, convert int64 to int32.
+    std::vector<int32_t> int32_data;
+    if (should_convert_int64_to_int32) {
+      try {
+        int32_data = GetNarrowedIntfromInt64<int32_t>(
+            gsl::span<const int64_t>(reinterpret_cast<int64_t*>(tensor_ptr), num_elements));
+        LOGS(logger, VERBOSE) << "Initializer '" << tensor.name() << "' is converted from int64 to int32.";
+      } catch (const std::exception& e) {
+        return ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT, e.what());
+      }
+      view = emscripten::val{emscripten::typed_memory_view(num_elements, int32_data.data())};
+
+      desc.set("dataType", emscripten::val("int32"));
+    }
+
+    // Wasm memory grow will cause all array buffers reallocation, which will be treated as detached
path: onnxruntime/core/providers/webnn/builders/model_builder.cc
user: login: fdwr
body: ```suggestion
    // Wasm memory growth will cause all array buffers reallocation, which will be treated as detached
```
created_at: 2025-04-07T05:33:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -274,20 +274,20 @@ bool IsMLTensorSupported() {
 
 // Convert int8 to uint4/int4 (stored as uint8)
 uint8_t PackInt8ToUint8AsNibble(int8_t value, const int32_t& data_type) {
path: onnxruntime/core/providers/webnn/builders/helper.cc
user: login: Honry
body: This method is used to concat an int4/uint4 value (present in int8) to a uint8, used for creating a WebNN constant with the data of same values for uint4/int4 data type.
 
e.g. the input `zero_points`' default value is 8 in `MatMulNBits`, we need to construct a WebNN constant with data type `uint4` and all the value to be 8. (we use Uint8Array to represent int4/uint4, so a `uint8` represents two `uint4/int4` data, if the value of `uint4` is 8, the `uint8` value should be 0x88).
 
The previous code has a bug that it define the `result = 0` at the beginning, when it calls
 
`result |= (static_cast<uint8_t>(value) << 4);`
 
if the value is 8, the result will be 0x80, but we need 0x88.

The name of this function is confusing, I will use `PackInt8ToUint8DoubledNibbles`, thanks for the suggestion.
created_at: 2025-04-07T06:59:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: Honry
created_at: 2025-03-24T07:53:47Z
body: @fdwr, @guschmue, PTAL, thanks!
author_association: CONTRIBUTOR
user: login: fdwr
created_at: 2025-04-07T04:57:05Z
body: /azp run ONNX Runtime Web CI Pipeline,Windows GPU CI Pipeline,Linux Android Emulator QNN CI Pipeline,Windows GPU WebGPU CI Pipeline,Windows OpenVINO CI Pipeline

author_association: CONTRIBUTOR
user: login: fdwr
created_at: 2025-04-07T04:57:08Z
body: /azp run Linux CPU CI Pipeline,Linux CPU Minimal Build E2E CI Pipeline,Linux GPU CI Pipeline,Linux GPU TensorRT CI Pipeline,Linux OpenVINO CI Pipeline,Linux QNN CI Pipeline,MacOS CI Pipeline,Windows ARM64 QNN CI Pipeline,Windows CPU CI Pipeline

author_association: CONTRIBUTOR
user: login: fdwr
created_at: 2025-04-07T04:57:10Z
body: /azp run Windows GPU CUDA CI Pipeline,Windows GPU DML CI Pipeline,Windows GPU Doc Gen CI Pipeline,Win_TRT_Minimal_CUDA_Test_CI

author_association: CONTRIBUTOR
user: login: fdwr
created_at: 2025-04-07T04:57:12Z
body: /azp run Windows GPU TensorRT CI Pipeline,onnxruntime-binary-size-checks-ci-pipeline,orttraining-linux-ci-pipeline,orttraining-linux-gpu-ci-pipeline,orttraining-ortmodule-distributed,Windows x64 QNN CI Pipeline,Big Models

author_association: CONTRIBUTOR
user: login: azure-pipelines[bot]
created_at: 2025-04-07T04:57:14Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: azure-pipelines[bot]
created_at: 2025-04-07T04:57:21Z
body: <samp>
Azure Pipelines successfully started running 2 pipeline(s).<br>

</samp>
author_association: NONE
user: login: azure-pipelines[bot]
created_at: 2025-04-07T04:57:25Z
body: <samp>
Azure Pipelines successfully started running 3 pipeline(s).<br>

</samp>
author_association: NONE
user: login: azure-pipelines[bot]
created_at: 2025-04-07T04:57:25Z
body: <samp>
Azure Pipelines successfully started running 4 pipeline(s).<br>

</samp>
author_association: NONE
user: login: Honry
created_at: 2025-04-07T06:43:46Z
body: @fdwr, thanks for your comments, I've fixed them in the new commit, PTAL again.
author_association: CONTRIBUTOR
user: login: fdwr
created_at: 2025-04-08T01:25:16Z
body: /azp run ONNX Runtime Web CI Pipeline,Windows GPU CI Pipeline,Linux Android Emulator QNN CI Pipeline,Windows GPU WebGPU CI Pipeline,Windows OpenVINO CI Pipeline

author_association: CONTRIBUTOR
user: login: fdwr
created_at: 2025-04-08T01:25:19Z
body: /azp run Linux CPU CI Pipeline,Linux CPU Minimal Build E2E CI Pipeline,Linux GPU CI Pipeline,Linux GPU TensorRT CI Pipeline,Linux OpenVINO CI Pipeline,Linux QNN CI Pipeline,MacOS CI Pipeline,Windows ARM64 QNN CI Pipeline,Windows CPU CI Pipeline

author_association: CONTRIBUTOR
user: login: fdwr
created_at: 2025-04-08T01:25:22Z
body: /azp run Windows GPU CUDA CI Pipeline,Windows GPU DML CI Pipeline,Windows GPU Doc Gen CI Pipeline,Win_TRT_Minimal_CUDA_Test_CI

author_association: CONTRIBUTOR
user: login: fdwr
created_at: 2025-04-08T01:25:23Z
body: /azp run Windows GPU TensorRT CI Pipeline,onnxruntime-binary-size-checks-ci-pipeline,orttraining-linux-ci-pipeline,orttraining-linux-gpu-ci-pipeline,orttraining-ortmodule-distributed,Windows x64 QNN CI Pipeline,Big Models

author_association: CONTRIBUTOR
user: login: azure-pipelines[bot]
created_at: 2025-04-08T01:25:26Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: azure-pipelines[bot]
created_at: 2025-04-08T01:25:34Z
body: <samp>
Azure Pipelines successfully started running 2 pipeline(s).<br>

</samp>
author_association: NONE
user: login: azure-pipelines[bot]
created_at: 2025-04-08T01:25:38Z
body: <samp>
Azure Pipelines successfully started running 3 pipeline(s).<br>

</samp>
author_association: NONE
user: login: azure-pipelines[bot]
created_at: 2025-04-08T01:25:43Z
body: <samp>
Azure Pipelines successfully started running 4 pipeline(s).<br>

</samp>
author_association: NONE