Skip to content

[MLAS] Riscv64 rvv kernel optimizations - #32540

Open
Zestion wants to merge 5 commits into
microsoft:mainfrom
Zestion:riscv64-rvv-kernel-optimizations
Open

Zestion wants to merge 5 commits into
microsoft:mainfrom
Zestion:riscv64-rvv-kernel-optimizations

Conversation

@Zestion

@Zestion Zestion commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Description

Improve FP32 inference in the MLAS riscv64 RVV backend with five commits:

  1. Softmax: accumulate across each row, reduce once at the end, and use LMUL 4.
  2. NCHWc convolution: compute four adjacent outputs with independent accumulators and shared filter loads.
  3. NCHWc register groups: select LMUL 1/2/4 from the vector length, preserving the sixteen-float block layout.
  4. Depthwise convolution: extend the existing sconv_depthwise_kernel_rvv.cpp to additional shapes and strides,
    with unit dilation and kernel width up to sixteen. Enable the wider route only when RVV kernels are installed.
  5. Activation: add an RVV MlasActivation routine, including the clamp used by ReLU6.

Changes are limited to MLAS and its CMake source list. Public interfaces remain unchanged.

Performance

A210/C920V2: VLEN=128; SpacemiT K3/X100: VLEN=256.
Baseline is unmodified upstream main at 9f913ae52, which already includes #32406.
Optimized is that baseline with this PR's five RVV kernel changes.
Both use the CPU Execution Provider, ORT_ENABLE_ALL, and RVV kernels enabled, with the same toolchain
and build configuration. Runs use one pinned core, one thread, warmup, fixed frequency, and best of three.
Speedup is baseline latency divided by optimized latency. These measurements precede the final depthwise
file consolidation and latest rebase.

Model Input shape C920V2 baseline (ms) C920V2 optimized (ms) Speedup X100 baseline (ms) X100 optimized (ms) Speedup
retinaface 1x640x640x3 NHWC 22300.2 7404.1 3.01x 11327.5 4037.0 2.81x
resnet50-v1-7 1x3x224x224 NCHW 1398.1 567.3 2.46x 816.3 271.6 3.01x
mobilenetv2-12 1x3x224x224 NCHW 148.2 70.7 2.10x 93.9 30.4 3.09x
mobilevit-s 1x3x256x256 NCHW 845.8 419.7 2.02x 356.8 200.2 1.78x
shufflenet-v2-10 1x3x224x224 NCHW 68.5 47.2 1.45x 41.9 20.3 2.06x
efficientnet-b0 1x3x224x224 NCHW 244.1 189.5 1.29x 124.3 67.1 1.85x

RetinaFace uses the official serengil TensorFlow/ResNet50 implementation and weights, exported with tf2onnx
at opset 13.

Validation

Before the latest rebase: XuanTie cross-compilation passed under -Werror. After depthwise consolidation,
the K3 convolution/activation subset passed 1109 tests with RVV and 565 with forced scalar,
with zero failures and three HalfConv skips in each mode. Softmax/log-softmax differential checks against
a double-precision reference passed on both boards.

Motivation and Context

Follow-up to #28261 and #28411, building on the runtime gate restored in #32406.
The existing NCHWc kernels reached only 0.20x–0.84x of the alternative convolution path's throughput
across seven measured shapes. Independent accumulators and appropriately sized register groups address
these bottlenecks while preserving the blocked layout.

Broader depthwise routing avoids im2col plus a single-row GEMM per channel. It benefits convolutions
that cannot use NCHWc, including some ShuffleNet layers at the default optimization level, as well as
ORT_ENABLE_EXTENDED. Fewer softmax reductions and vectorized activation address the remaining overhead.

The two reducing kernels issued a vector reduction for every group of a row
rather than carrying the result in a vector and reducing at the end, which is
what the generic implementations do. A reduction costs several times what an
add or a max costs, so a row became a chain of them and the vector unit spent
most of it waiting.

The group width goes from LMUL 1 to LMUL 4 at the same time. The two are worth
separating in principle but not in this file: the polynomial in the exponential
is a dependency chain either way, and what covers it is having a group wide
enough to keep independent work in flight. LMUL 8 is not taken, since it leaves
the register file four names and spills on the longer rows.

The polynomial stays a multiply and an add. Horner's form wants the addend to
be the constant, and the fused instruction takes its addend from a vector, so
each step would first have to broadcast the constant into one: an instruction
and a register group per step, for no gain.

Carrying the sum in a vector changes the order the row is added in, so the
result is not the one the previous kernel produced, in the same way that the
generic implementations already differ from each other by lane count. The
exponentials themselves are untouched.
The convolution and pooling kernels here hold a channel block in one
register group, and each of them accumulated a single output position at
a time. Unlike a back end whose registers are narrower than a block,
that leaves no work to overlap: every multiply accumulate for a position
depends on the one before it, so the kernels ran at the latency of the
multiply accumulate rather than its throughput, and the filter vector was
loaded again for every position that read it.

Accumulate four adjacent output positions at once instead. The chains are
then independent of one another and one filter load serves all four. A
register group cannot be held in an array, so the positions are spelled
out, and a group holding fewer than four repeats the first position in the
slots it does not use, which is what lets one body serve both a full group
and the remainder. The filter block becomes the outer loop so that it stays
resident while the positions that read it are swept.

Convolution shapes from resnet50 and mobilenetv2, single core, in GFLOPS
against the XuanTie C920V2 cores of an A210, at 128 bits, and a SpacemiT K3,
whose X100 cores are at 256:

                          C920V2            X100
    56x56 64->64 3x3     4.8 -> 14.1     8.7 -> 15.5
    56x56 64->256 1x1    5.8 -> 10.5     9.2 -> 16.0
    28x28 128->128 3x3   7.3 -> 18.2     8.6 -> 15.7
    14x14 256->256 3x3   6.8 -> 14.0     8.0 -> 14.2
    7x7 512->512 3x3     3.9 -> 11.2     8.2 -> 14.7
    224x224 3->64 7x7    5.7 -> 13.6     9.0 -> 15.2
    112x112 32->16 1x1   1.1 ->  7.5     8.8 -> 14.1

Every one of those shapes was slower through this path than through the
convolution the dispatch selects when the NCHWc path is unavailable, and
none of them is now.
A channel block is sixteen floats, and these kernels asked for it from a
group of four registers whatever the part was. That is the right group at
128 bits and too wide above it: the cost of a vector instruction follows
the multiplier it was issued with rather than the number of elements it was
asked for, so at 256 bits every instruction in these kernels was paid for
at four registers and half of what it was paid for went unused.

Write the kernels once against a set of accessors and instantiate them for
each multiplier a block can live in, then enter the instantiation the part
wants: four registers at 128 bits, two at 256, one at 512. The block size
and therefore the memory layout are unchanged, so a filter reordered on one
part is still read correctly on another, and the results are unchanged as
well.

The same convolution shapes, single core, in GFLOPS. The C920V2 is at 128
bits and keeps four registers, so it is unaffected; the X100 is at 256 and
drops to two:

                          C920V2            X100
    56x56 64->64 3x3          14.1      15.5 -> 32.4
    56x56 64->256 1x1         10.5      16.0 -> 26.7
    28x28 128->128 3x3        18.2      15.7 -> 31.9
    14x14 256->256 3x3        14.6      14.2 -> 27.8
    7x7 512->512 3x3          11.1      14.7 -> 24.4
    224x224 3->64 7x7         13.5      15.2 -> 27.6
    112x112 32->16 1x1         7.4      14.1 -> 24.9
The direct depthwise path admits the three by three shape at stride one, and every
other shape goes through im2col and a GEMM per channel. A depthwise convolution
gives that GEMM a single row, so nearly all of its time goes into building the
column buffer and packing a panel for one row of arithmetic.

There is now a kernel for the general shape at dilation one, and MlasConvPrepare
admits those shapes. Admitting them is only safe because the kernel reports
success for every shape it is offered: the fallback the dispatch would otherwise
reach handles three by three alone and would silently compute the wrong result.

Two bodies are emitted. The three by three one follows the shape of the kernel it
sits beside, whose row pointers already make a row's taps contiguous. The general
one cannot borrow that shape, because a wider kernel loses too much to the edges:
on a seven by seven image a five by five kernel pads by two, so leaving the padded
columns scalar would leave four of seven columns scalar. It vectorizes the whole
row and asks per tap which output columns that tap reaches, which is an interval
whose two ends are not alike -- a tap starting before the image loses leading
lanes, which no vl or pointer offset reaches, while one reaching past the end
loses trailing lanes, which shortening vl drops exactly.

LMUL is chosen at run time rather than fixed. A row is only as wide as the image
and a vector operation costs what its LMUL says regardless of how much of it the
current vl uses, so a tile wider than the row wastes most of the machine, and how
wide a fixed LMUL is depends on the vector length. The body is written once and
instantiated at LMUL 1, 2 and 4.

The results are not bit-identical to the kernel this extends, and cannot be: that
kernel leaves its expression for the compiler to contract and the summation
topology differs. This is the same class of difference the direct path already
introduces against the im2col path it replaces.

The widened routing is conditional on that kernel being installed, since a
build for the vector extension still has to run on a part that does not
implement it, and the algorithm it would otherwise select handles 3x3 alone.
Copilot AI balanced review requested due to automatic review settings September 10, 2026 10:12
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@Zestion Zestion changed the title Riscv64 rvv kernel optimizations [MLAS] Riscv64 rvv kernel optimizations Sep 10, 2026
@Zestion

Zestion commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Hariharan Seshadri (@hariharans29) Xavier Dupré (@xadupre) Could you please review this PR when you get a chance? Thanks!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

platform.cpp references ActivationRoutine outside the MLAS_USE_RVV guard, which breaks compilation for riscv64 builds that don’t enable RVV.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR improves FP32 inference performance on riscv64 by expanding and optimizing MLAS RVV kernels (softmax/log-softmax, multiple convolution variants, and fused activations) while keeping public interfaces unchanged.

Changes:

  • Optimize RVV softmax/log-softmax primitives by accumulating across a row and reducing once, using LMUL=4.
  • Rework RVV NCHW/NCHWc/depthwise/pointwise conv kernels to compute multiple adjacent outputs per iteration and dispatch LMUL based on vector length.
  • Add an RVV MlasActivation fast path for elementwise fused activations (e.g., ReLU, Clip, HardSigmoid) and wire it into platform initialization and CMake.
File summaries
File Description
onnxruntime/core/mlas/lib/riscv64/softmax_kernel_rvv.cpp Softmax/log-softmax RVV row-accumulation and LMUL=4 refactor.
onnxruntime/core/mlas/lib/riscv64/sconv_nchwc_kernel_rvv.cpp NCHW/NCHWc/depthwise/pointwise/pooling RVV kernels restructured with multi-output accumulation and LMUL dispatch.
onnxruntime/core/mlas/lib/riscv64/sconv_depthwise_kernel_rvv.cpp Extend RVV depthwise CHW kernel beyond 3x3 and add runtime LMUL selection for general kernels.
onnxruntime/core/mlas/lib/riscv64/conv_activation_kernel_rvv.cpp New RVV fused-activation implementation for elementwise activation kinds (with optional bias).
onnxruntime/core/mlas/lib/platform.cpp Wire RVV activation routine into riscv64 platform dispatch (plus other RVV hooks).
onnxruntime/core/mlas/lib/mlasi.h Add MLAS_ACTIVATION_ROUTINE, RVV activation symbol, and riscv64 RVV depthwise-kernel-width constant.
onnxruntime/core/mlas/lib/convolve.cpp Widen depthwise routing on riscv64 when RVV kernels are installed (avoids im2col+GEMM-per-channel).
onnxruntime/core/mlas/lib/activate.cpp Add riscv64 RVV activation fast path before generic activation switch.
cmake/onnxruntime_mlas.cmake Add the new RVV activation source to the MLAS build when RVV is enabled.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread onnxruntime/core/mlas/lib/platform.cpp Outdated
Comment thread onnxruntime/core/mlas/lib/activate.cpp
@Zestion

Zestion commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Hi velonica0, this PR builds on your RVV NCHWc work in #28411. Since you previously benchmarked those kernels on K3, would you be willing to cross-check these changes on your board when convenient? Testing a few models, such as ResNet50, MobileNetV2, or ShuffleNet, for correctness and performance would be very helpful. Thanks!

MlasActivation is written against MLAS_FLOAT32X4 and splits every row into a four
wide body and a scalar remainder, so a convolution's fused activation moved four
elements at a time however wide the vector unit is. The element-wise kinds are
covered here by a platform routine, which returns false for the two that
MlasActivation applies the bias for and then routes through MlasComputeTanh and
MlasComputeLogistic; those keep going through the switch. Each kind reproduces the
arithmetic of its MLAS_ACTIVATION_FUNCTION specialization, in the same order, so
the results are the same.
@Zestion
Zestion force-pushed the riscv64-rvv-kernel-optimizations branch from 7a9b090 to b44bb44 Compare September 10, 2026 10:44
@hariharans29

Copy link
Copy Markdown
Member

CC: mirounga for the review

@velonica0

Copy link
Copy Markdown
Contributor

Yes, these modifications significantly improve performance!

Results (1 thread, ms)

Model Baseline This PR Speedup (measured) PR claims (X100)
resnet50-v1-7 894.3 289.3 3.09x 3.01x
mobilenetv2-12 102.4 32.7 3.13x 3.09x
shufflenet-v2-10 45.6 20.0 2.28x 2.06x
mobilevit-s 392.1 209.3 1.87x 1.78x

@Zestion

Zestion commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Hi mirounga, could you take a look when you have a chance? I've addressed the Copilot comments, and the updated activation tests pass on A210 and K3 in RVV and scalar modes. Happy to provide any additional details. Thanks!

@mirounga
mirounga self-requested a review September 16, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants