Repository navigation
[WebGPU] Support block-quantized FP8 dense MatMul #33074
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 11 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
cbcd5e9
Add WebGPU block-quantized FP8 dense matmul
sayanshaw24 255d4b3
Preserve FP16-only ops and select registered WebGPU plugin in Python
sayanshaw24 3e99036
Make FP8 activation rounding portable across WebGPU backends
sayanshaw24 c6ca2e2
Declare FP8 activation shader type dependencies
sayanshaw24 b7c0e85
Merge remote-tracking branch 'origin/main' into webgpu-fp8-block-matmul
sayanshaw24 2f12bcd
Compare FP8 activation ties in input space
sayanshaw24 ba64210
Merge remote-tracking branch 'origin/main' into webgpu-fp8-block-matmul
sayanshaw24 38a1262
Fix FP8 WebGPU dispatch, subgroup ownership and quantization ties
sayanshaw24 dc9a8e1
Compare FP8 activation midpoints in reciprocal domain
sayanshaw24 f86ef3c
Merge main with ONNX materialized-node test fix
sayanshaw24 9268738
Merge remote-tracking branch 'origin/main' into webgpu-fp8-block-matmul
sayanshaw24 d9718cf
Merge current main into WebGPU block-FP8 dense MatMul
sayanshaw24 4905cd0
Preserve FP8 activation NaNs and gate subgroup ID shader inputs
sayanshaw24 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
27 changes: 27 additions & 0 deletions
27
...ntime/contrib_ops/webgpu/quantization/matmul_block_quantized_fp8_activation.wgsl.template
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| #use .getByOffset .setByOffset | ||
|
|
||
| const E4M3_MAX: f32 = 448.0; | ||
|
|
||
| $MAIN { | ||
| if (global_idx >= uniforms.size) { | ||
| return; | ||
| } | ||
| let scale = input_scale.getByOffset(0u); | ||
| let inverse = select(0.0, 1.0 / scale, scale != 0.0); | ||
| let input_value = f32(input_a.getByOffset(global_idx)); | ||
| let v = input_value * inverse; | ||
| let magnitude = min(abs(v), E4M3_MAX); | ||
| let magnitude_exponent = (bitcast<u32>(magnitude) >> 23u) & 255u; | ||
| let step = bitcast<f32>((max(magnitude_exponent, 121u) - 3u) << 23u); | ||
| let units = magnitude / step; | ||
| let lower = floor(units); | ||
| let halfway = (lower + 0.5) * step; | ||
| let round_up = magnitude > halfway || | ||
| (magnitude == halfway && (u32(lower) & 1u) != 0u); | ||
| let rounded = min((lower + select(0.0, 1.0, round_up)) * step, E4M3_MAX); | ||
| let q = select(rounded, -rounded, v < 0.0); | ||
| output.setByOffset(global_idx, f16(q * scale)); | ||
| } | ||
216 changes: 216 additions & 0 deletions
216
onnxruntime/contrib_ops/webgpu/quantization/matmul_block_quantized_fp8_weight.cc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| #include "contrib_ops/webgpu/quantization/matmul_block_quantized_fp8_weight.h" | ||
|
|
||
| #include "contrib_ops/webgpu/webgpu_contrib_kernels.h" | ||
| #include "core/common/narrow.h" | ||
| #include "core/providers/cpu/math/matmul_helper.h" | ||
| #include "core/providers/webgpu/math/subgroup_matrix_config.h" | ||
| #include "core/providers/webgpu/shader_helper.h" | ||
| #include "core/providers/webgpu/webgpu_supported_types.h" | ||
| #include "core/providers/webgpu/webgpu_utils.h" | ||
| #if !defined(DISABLE_FLOAT8_TYPES) | ||
| #include "core/common/float8.h" | ||
| #endif | ||
|
|
||
| namespace onnxruntime { | ||
| namespace contrib { | ||
| namespace webgpu { | ||
|
|
||
| using namespace onnxruntime::webgpu; | ||
|
|
||
| namespace { | ||
|
|
||
| class Fp8ActivationProgram final : public Program<Fp8ActivationProgram> { | ||
| public: | ||
| Fp8ActivationProgram() : Program{"Fp8MatMulActivationQdq"} {} | ||
|
|
||
| Status GenerateShaderCode(ShaderHelper& shader) const override { | ||
| const auto& input_a = shader.AddInput("input_a", ShaderUsage::UseValueTypeAlias); | ||
| const auto& input_scale = shader.AddInput("input_scale", ShaderUsage::UseValueTypeAlias); | ||
| const auto& output = shader.AddOutput("output", ShaderUsage::UseValueTypeAlias); | ||
| return WGSL_TEMPLATE_APPLY(shader, "quantization/matmul_block_quantized_fp8_activation.wgsl.template", | ||
| WGSL_TEMPLATE_VARIABLE(input_a, input_a), | ||
| WGSL_TEMPLATE_VARIABLE(input_scale, input_scale), | ||
| WGSL_TEMPLATE_VARIABLE(output, output)); | ||
| } | ||
| WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES({"size", ProgramUniformVariableDataType::Uint32}); | ||
| }; | ||
|
|
||
| class Fp8EmptyReductionProgram final : public Program<Fp8EmptyReductionProgram> { | ||
| public: | ||
| explicit Fp8EmptyReductionProgram(bool has_bias) | ||
| : Program{"Fp8MatMulEmptyReduction"}, has_bias_(has_bias) {} | ||
|
|
||
| Status GenerateShaderCode(ShaderHelper& shader) const override { | ||
| if (has_bias_) { | ||
| const auto& bias = shader.AddInput("bias"); | ||
| const auto& output = shader.AddOutput("output"); | ||
| shader.MainFunctionBody() << shader.GuardAgainstOutOfBoundsWorkgroupSizes("uniforms.size") | ||
| << output.SetByOffset("global_idx", bias.GetByOffset("global_idx % uniforms.N")); | ||
| } else { | ||
| const auto& output = shader.AddOutput("output"); | ||
| shader.MainFunctionBody() << shader.GuardAgainstOutOfBoundsWorkgroupSizes("uniforms.size") | ||
| << output.SetByOffset("global_idx", "f16(0)"); | ||
| } | ||
| return Status::OK(); | ||
| } | ||
| WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES({"size", ProgramUniformVariableDataType::Uint32}, | ||
| {"N", ProgramUniformVariableDataType::Uint32}); | ||
|
|
||
| private: | ||
| bool has_bias_; | ||
| }; | ||
|
|
||
| class Fp8MatMulProgram final : public Program<Fp8MatMulProgram> { | ||
| public: | ||
| Fp8MatMulProgram(bool has_bias, bool use_matrix) | ||
| : Program{"MatMulBlockQuantizedFp8Weight"}, has_bias_(has_bias), use_matrix_(use_matrix) {} | ||
|
|
||
| Status GenerateShaderCode(ShaderHelper& shader) const override { | ||
| const auto& input_a = shader.AddInput("input_a", ShaderUsage::UseValueTypeAlias); | ||
| const auto& weights = shader.AddInput("weights", ShaderUsage::UseValueTypeAlias); | ||
| const auto& scales = shader.AddInput("scales", ShaderUsage::UseValueTypeAlias); | ||
| const ShaderVariableHelper* bias = nullptr; | ||
| if (has_bias_) { | ||
| bias = &shader.AddInput("bias", ShaderUsage::UseValueTypeAlias); | ||
| } | ||
| const auto& output = shader.AddOutput("output", ShaderUsage::UseValueTypeAlias); | ||
| return WGSL_TEMPLATE_APPLY(shader, "quantization/matmul_block_quantized_fp8_weight.wgsl.template", | ||
| WGSL_TEMPLATE_PARAMETER(has_bias, has_bias_), | ||
| WGSL_TEMPLATE_PARAMETER(use_matrix, use_matrix_), | ||
| WGSL_TEMPLATE_OPTIONAL_VARIABLE(bias, bias), | ||
| WGSL_TEMPLATE_VARIABLE(input_a, input_a), | ||
| WGSL_TEMPLATE_VARIABLE(output, output), | ||
| WGSL_TEMPLATE_VARIABLE(scales, scales), | ||
| WGSL_TEMPLATE_VARIABLE(weights, weights)); | ||
| } | ||
|
|
||
| WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES({"M", ProgramUniformVariableDataType::Uint32}, | ||
| {"N", ProgramUniformVariableDataType::Uint32}, | ||
| {"K", ProgramUniformVariableDataType::Uint32}, | ||
| {"K_blocks", ProgramUniformVariableDataType::Uint32}, | ||
| {"block_size", ProgramUniformVariableDataType::Uint32}); | ||
|
|
||
| private: | ||
| bool has_bias_; | ||
| bool use_matrix_; | ||
| }; | ||
|
|
||
| } // namespace | ||
|
|
||
| MatMulBlockQuantizedFp8Weight::MatMulBlockQuantizedFp8Weight(const OpKernelInfo& info) | ||
| : WebGpuKernel(info), | ||
| block_size_(narrow<uint32_t>(info.GetAttrOrDefault<int64_t>("block_size", 128))) { | ||
| ORT_ENFORCE(block_size_ > 0, "block_size must be positive."); | ||
| } | ||
|
|
||
| Status MatMulBlockQuantizedFp8Weight::ComputeInternal(onnxruntime::webgpu::ComputeContext& context) const { | ||
| const Tensor* a = context.Input(0); | ||
| const Tensor* b = context.Input(1); | ||
| const Tensor* scales = context.Input(2); | ||
| const Tensor* a_scale = context.Input(3); | ||
| const Tensor* bias = context.Input(4); | ||
|
|
||
| const auto& a_shape = a->Shape(); | ||
| const auto& b_shape = b->Shape(); | ||
| ORT_RETURN_IF_NOT(a_shape.NumDimensions() >= 1 && b_shape.NumDimensions() == 2, | ||
| "A must have rank >= 1 and B must have shape [N, K]."); | ||
| const int64_t n_dim = b_shape[0]; | ||
| const int64_t k_dim = b_shape[1]; | ||
| ORT_RETURN_IF_NOT(a_shape[a_shape.NumDimensions() - 1] == k_dim, | ||
| "A and B contraction dimensions must match."); | ||
| const int64_t k_blocks = (k_dim + block_size_ - 1) / block_size_; | ||
| ORT_RETURN_IF_NOT(scales->Shape() == TensorShape({n_dim, k_blocks}), | ||
| "b_scale must have shape [N, ceil(K/block_size)]."); | ||
| ORT_RETURN_IF_NOT(a_scale == nullptr || a_scale->Shape().Size() == 1, | ||
| "a_scale must contain one value."); | ||
| ORT_RETURN_IF_NOT(bias == nullptr || bias->Shape() == TensorShape({n_dim}), | ||
| "bias must have shape [N]."); | ||
|
|
||
| MatMulComputeHelper helper; | ||
| ORT_RETURN_IF_ERROR(helper.Compute(a_shape, b_shape, false, true)); | ||
| Tensor* y = context.Output(0, helper.OutputShape()); | ||
| if (y->Shape().Size() == 0) { | ||
| return Status::OK(); | ||
| } | ||
|
|
||
| const uint32_t K = narrow<uint32_t>(k_dim); | ||
| const uint32_t N = narrow<uint32_t>(n_dim); | ||
| const uint32_t M = narrow<uint32_t>(y->Shape().Size() / n_dim); | ||
| const uint32_t K_blocks = narrow<uint32_t>(k_blocks); | ||
| if (K == 0) { | ||
| Fp8EmptyReductionProgram empty_program{bias != nullptr}; | ||
| empty_program.SetWorkgroupSize(64); | ||
| empty_program.SetDispatchGroupSize((narrow<uint32_t>(y->Shape().Size()) + 63u) / 64u); | ||
| if (bias) { | ||
| empty_program.AddInput({bias, ProgramTensorMetadataDependency::Type}); | ||
| } | ||
| empty_program.AddOutput({y, ProgramTensorMetadataDependency::Type}) | ||
| .AddUniformVariables({{narrow<uint32_t>(y->Shape().Size())}, {N}}); | ||
| return context.RunProgram(empty_program); | ||
| } | ||
| const Tensor* activation = a; | ||
| Tensor qdq; | ||
| if (a_scale) { | ||
| qdq = context.CreateGPUTensor(a->DataType(), a_shape); | ||
| Fp8ActivationProgram qdq_program; | ||
| qdq_program.SetWorkgroupSize(64); | ||
| qdq_program.SetDispatchGroupSize((narrow<uint32_t>(a_shape.Size()) + 63u) / 64u); | ||
| qdq_program.AddInputs({{a, ProgramTensorMetadataDependency::Type}, | ||
| {a_scale, ProgramTensorMetadataDependency::Type}}) | ||
| .AddOutput({&qdq, ProgramTensorMetadataDependency::Type}) | ||
| .AddUniformVariables({{narrow<uint32_t>(a_shape.Size())}}); | ||
| ORT_RETURN_IF_ERROR(context.RunProgram(qdq_program)); | ||
| activation = &qdq; | ||
| } | ||
|
|
||
| const bool use_matrix = M >= 8 && | ||
| SelectSubgroupMatrixConfig(context, {{wgpu::SubgroupMatrixComponentType::F16, | ||
| wgpu::SubgroupMatrixComponentType::F32, | ||
| 16, 16, 16, 32, false}}) | ||
| .has_value(); | ||
|
|
||
| auto memory_info = OrtMemoryInfo{ | ||
| WEBGPU_BUFFER, | ||
| OrtDeviceAllocator, | ||
| OrtDevice{OrtDevice::GPU, OrtDevice::MemType::DEFAULT, OrtDevice::VendorIds::NONE, 0}}; | ||
| // Tensor views require void*, but the alias only binds B as read-only storage; no copy or mutation occurs. | ||
| Tensor weight_bytes(DataTypeImpl::GetType<uint8_t>(), b_shape, const_cast<void*>(b->DataRaw()), memory_info); | ||
|
sayanshaw24 marked this conversation as resolved.
Resolved
|
||
| Fp8MatMulProgram program{bias != nullptr, use_matrix}; | ||
| program.SetWorkgroupSize(use_matrix ? 128 : 32); | ||
| if (use_matrix && context.HasFeature(wgpu::FeatureName::SubgroupSizeControl)) { | ||
| program.SetSubgroupSize(32); | ||
| } | ||
| program.SetDispatchGroupSize(use_matrix ? (N + 15u) / 16u : N, | ||
| use_matrix ? (M + 63u) / 64u : M); | ||
| // Flatten rounds the byte count up to u32 words; WebGPU buffers are 16-byte padded by BufferManager. | ||
| // The shader extracts only bytes whose offset is within N*K, even in the final partial word. | ||
| program.AddInputs({{activation, ProgramTensorMetadataDependency::Type}, | ||
| {&weight_bytes, ProgramTensorMetadataDependency::Type, ProgramInput::Flatten, 4}, | ||
| {scales, ProgramTensorMetadataDependency::Type}}) | ||
|
sayanshaw24 marked this conversation as resolved.
Resolved
|
||
| .AddOutput({y, ProgramTensorMetadataDependency::Type}) | ||
| .AddUniformVariables({{M}, {N}, {K}, {K_blocks}, {block_size_}}); | ||
| if (bias) { | ||
| program.AddInput({bias, ProgramTensorMetadataDependency::Type}); | ||
| } | ||
| return context.RunProgram(program); | ||
| } | ||
|
|
||
| #if !defined(DISABLE_FLOAT8_TYPES) | ||
| ONNX_OPERATOR_KERNEL_EX( | ||
| MatMulBlockQuantizedFp8Weight, | ||
| kMSDomain, | ||
| 1, | ||
| kWebGpuExecutionProvider, | ||
| (*KernelDefBuilder::Create()) | ||
| .TypeConstraint("T", DataTypeImpl::GetTensorType<MLFloat16>()) | ||
| .TypeConstraint("T1", DataTypeImpl::GetTensorType<Float8E4M3FN>()) | ||
| .TypeConstraint("T2", DataTypeImpl::GetTensorType<float>()), | ||
| MatMulBlockQuantizedFp8Weight); | ||
| #endif | ||
|
|
||
| } // namespace webgpu | ||
| } // namespace contrib | ||
| } // namespace onnxruntime | ||
24 changes: 24 additions & 0 deletions
24
onnxruntime/contrib_ops/webgpu/quantization/matmul_block_quantized_fp8_weight.h
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| #pragma once | ||
|
|
||
| #include "core/providers/webgpu/program.h" | ||
| #include "core/providers/webgpu/webgpu_kernel.h" | ||
|
|
||
| namespace onnxruntime { | ||
| namespace contrib { | ||
| namespace webgpu { | ||
|
|
||
| class MatMulBlockQuantizedFp8Weight final : public onnxruntime::webgpu::WebGpuKernel { | ||
| public: | ||
| explicit MatMulBlockQuantizedFp8Weight(const OpKernelInfo& info); | ||
| Status ComputeInternal(onnxruntime::webgpu::ComputeContext& context) const override; | ||
|
|
||
| private: | ||
| uint32_t block_size_; | ||
| }; | ||
|
|
||
| } // namespace webgpu | ||
| } // namespace contrib | ||
| } // namespace onnxruntime |
107 changes: 107 additions & 0 deletions
107
onnxruntime/contrib_ops/webgpu/quantization/matmul_block_quantized_fp8_weight.wgsl.template
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| #param has_bias | ||
| #param use_matrix | ||
| #use .getByOffset .setByOffset | ||
|
|
||
| fn decode_fp8(code: u32) -> f32 { | ||
| let exponent = (code >> 3u) & 15u; | ||
| let mantissa = code & 7u; | ||
| let magnitude = select(f32(mantissa) * 0.001953125, | ||
| bitcast<f32>(((exponent + 120u) << 23u) | (mantissa << 20u)), | ||
| exponent != 0u); | ||
| let value = select(magnitude, -magnitude, (code & 128u) != 0u); | ||
| return select(value, bitcast<f32>(0x7fc00000u | code), exponent == 15u && mantissa == 7u); | ||
| } | ||
|
|
||
| fn weight(n: u32, k: u32) -> f16 { | ||
| let offset = n * uniforms.K + k; | ||
| let code = (weights.getByOffset(offset / 4u) >> ((offset % 4u) * 8u)) & 255u; | ||
| return f16(decode_fp8(code) * scales.getByOffset(n * uniforms.K_blocks + k / uniforms.block_size)); | ||
| } | ||
|
|
||
| #if use_matrix | ||
| // The padded workgroup tiles make every subgroup-matrix load and store in bounds. | ||
| var<workgroup> tile_a: array<f16, 1024>; | ||
| var<workgroup> tile_b: array<f16, 256>; | ||
| var<workgroup> tile_y: array<f32, 1024>; | ||
| #else | ||
| var<workgroup> partial: array<f32, 32>; | ||
| #endif | ||
| $MAIN { | ||
| #if use_matrix | ||
| let lane = local_idx; | ||
| let n_tiles = (uniforms.N + 15u) / 16u; | ||
| if (workgroup_idx >= n_tiles * ((uniforms.M + 63u) / 64u)) { | ||
| return; | ||
| } | ||
| let m_base = (workgroup_idx / n_tiles) * 64u; | ||
| let n_base = (workgroup_idx % n_tiles) * 16u; | ||
| var acc: subgroup_matrix_result<f32, 16, 16>; | ||
| for (var tile = 0u; tile < (uniforms.K + 15u) / 16u; tile++) { | ||
| let k_base = tile * 16u; | ||
| for (var i = lane; i < 1024u; i += 128u) { | ||
| let m = m_base + i / 16u; | ||
| let k = k_base + i % 16u; | ||
| tile_a[i] = f16(0); | ||
| if (m < uniforms.M && k < uniforms.K) { | ||
| tile_a[i] = input_a.getByOffset(m * uniforms.K + k); | ||
| } | ||
| } | ||
| for (var i = lane; i < 256u; i += 128u) { | ||
| let n = n_base + i / 16u; | ||
| let k = k_base + i % 16u; | ||
| tile_b[i] = f16(0); | ||
| if (n < uniforms.N && k < uniforms.K) { | ||
| tile_b[i] = weight(n, k); | ||
| } | ||
| } | ||
| workgroupBarrier(); | ||
| let left = subgroupMatrixLoad<subgroup_matrix_left<f16, 16, 16>, row_major>( | ||
|
sayanshaw24 marked this conversation as resolved.
Resolved
|
||
| &tile_a, subgroup_index * 256u, 16u); | ||
| let right = subgroupMatrixLoad<subgroup_matrix_right<f16, 16, 16>, col_major>(&tile_b, 0u, 16u); | ||
| acc = subgroupMatrixMultiplyAccumulate(left, right, acc); | ||
| workgroupBarrier(); | ||
| } | ||
| subgroupMatrixStore<row_major>(&tile_y, subgroup_index * 256u, acc, 16u); | ||
| workgroupBarrier(); | ||
| for (var i = lane; i < 1024u; i += 128u) { | ||
| let m = m_base + i / 16u; | ||
| let n = n_base + i % 16u; | ||
| if (m < uniforms.M && n < uniforms.N) { | ||
| var value = tile_y[i]; | ||
| #if has_bias | ||
| value += f32(bias.getByOffset(n)); | ||
| #endif | ||
| output.setByOffset(m * uniforms.N + n, f16(value)); | ||
| } | ||
| } | ||
| #else | ||
| let lane = local_idx; | ||
| if (workgroup_idx >= uniforms.M * uniforms.N) { | ||
| return; | ||
| } | ||
| let m = workgroup_idx / uniforms.N; | ||
| let n = workgroup_idx % uniforms.N; | ||
| var sum = 0.0; | ||
| for (var k = lane; k < uniforms.K; k += 32u) { | ||
| sum += f32(input_a.getByOffset(m * uniforms.K + k)) * f32(weight(n, k)); | ||
| } | ||
| partial[lane] = sum; | ||
| workgroupBarrier(); | ||
| for (var stride = 16u; stride > 0u; stride /= 2u) { | ||
| if (lane < stride) { | ||
| partial[lane] += partial[lane + stride]; | ||
| } | ||
| workgroupBarrier(); | ||
| } | ||
| if (lane == 0u) { | ||
| var value = partial[0]; | ||
| #if has_bias | ||
| value += f32(bias.getByOffset(n)); | ||
| #endif | ||
| output.setByOffset(m * uniforms.N + n, f16(value)); | ||
| } | ||
| #endif | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.