Skip to content

[CPU:Perf] Vectorize the conv int8 bias-float init with a reciprocal - #4892

Open
typer-J wants to merge 1 commit into
alibaba:masterfrom
typer-J:bias-rcp
Open

typer-J wants to merge 1 commit into
alibaba:masterfrom
typer-J:bias-rcp

Conversation

@typer-J

@typer-J typer-J commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Summary

One RVV kernel for CPU ConvInt8 resource initialisation, registered in the CoreFunctions table under MNN_USE_RVV with a scalar fallback when the pointer is null (non-RISC-V and RVV=OFF builds keep the original scalar path).

MNNConvInt8ComputeBiasFloat converts the int32 conv bias to float as bias * weightScale * inputScale / outputScale. outputScale is loop-invariant, so the per-element division is replaced by a reciprocal computed once outside the loop. That division is what the loop's cost is dominated by: with it in place the kernel measures at parity with the scalar loop it replaces, and removing it is the whole gain.

This is one of two alternatives for the same change. The sibling PR (branch bias-mks) reaches the same speedup by the same route but adds a Markstein correction that makes the result bit-identical to the scalar loop. They touch the same lines, so only one can be merged. Please merge whichever you prefer and close the other.

Which one this is

this PR (bias-rcp) sibling (bias-mks)
kernel reciprocal, hoisted reciprocal + Markstein correction
speedup (size 4096) 9.33x 6.29x
vs the scalar loop 1.19 us vs 11.20 us 1.78 us vs 11.20 us
last bit on realistic scales differs on 24-33% of elements, 1 ULP 0%, bit-identical
max relative error 6.2e-06 0

Both are far below int8 quantisation noise (half an LSB is ~2.3e-3 relative), so on model quality the difference is not visible either way. The trade is speed against bit-exactness: the plain reciprocal is 1.48x faster than the corrected one.

Neither arm is preferred here. Both are offered so that the choice between them is a decision on the trade itself rather than on which one happens to be proposed first.

Board measurements

Board: riscv64, VLEN=128 (rv64imafdcvh), openEuler 24.03 SP3, GCC 14.3.1, 32 cores, taskset -c 8 single-core pinning. Kernel-only harness, no MNN build; every arm compared in the same run, min over 9 rounds with the arm order rotated.

arm size 4096 vs NoV scalar
NoV — scalar loop built for rv64gc, the code the kernel replaces 11.20 us 1.00x
same kernel with the division kept in the loop 11.67 us 0.96x
reciprocal, hoisted (this PR) 1.19 us 9.33x
reciprocal + Markstein correction (sibling PR) 1.78 us 6.29x

objdump -d confirms the NoV object contains zero vector instructions, so the baseline is genuinely scalar.

The unscaled path (inputScale or outputScale is zero) has no division in either form and all arms land together at about 4.0x over the scalar loop; the difference is only in the scaled path, which is the common one.

Accuracy

Against the original expression as reference, over 200 scale pairs x 819200 elements:

arm elements differing in the last bit max relative error
reciprocal only (this PR) 24.4% 6.2e-06
reciprocal + Markstein (sibling) 0.000% 0

On realistic quantisation scale pairs (is=1.7 os=3.0, is=0.375 os=0.375) this arm differs on 32-33% of elements by 1 ULP. On scale pairs that are exactly representable in binary (0.5, 2.0, 1.0) it differs on none, which confirms the difference is rounding and not a logic error.

This is the same class of change that was flagged in review on the earlier precomputed-ratio revision, so it is stated plainly rather than buried: t * (1.0f / os) is not always the correctly rounded t / os, and this arm accepts that. The sibling PR takes the other side of that trade.

Safety

1.0f / outputScale overflows to inf when outputScale is subnormal, which the original per-element division does not do. A check hoisted out of the loop fixes it:

const bool rcpOk = std::isfinite(r) && r != 0.0f && fabsf(r) >= 1.17549435e-38f;
// otherwise fall back to vfdiv_vf

Without the guard, an outputScale of 1e-39 or 5e-40 produced 1178 and 1039 non-finite outputs where the reference had none; with it, zero. The guard is evaluated once outside the loop, so the timing is unchanged (9.33x either way).

The unit test asserts that the kernel never produces a non-finite value where the reference is finite, and reports the last-bit difference count rather than hiding it behind a pass/fail bit.

Scope

This is resource-initialisation code that runs once per model load, so the absolute numbers are small either way: at 4096 output channels the saving is about 10 us per layer, and across 100 int8 conv layers averaging 512 channels it is roughly 0.1 ms per model load. The speedup is real and large in relative terms; in end-to-end terms it is small. That is worth knowing when weighing it against the accuracy difference above.

Test

  • Kernel-level unit test test/backend/cpu/RVVConvInt8BiasRcpTest.cpp: (inputScale, outputScale) pairs including non-representable values, one pair with inputScale == outputScale, and a zero on either side (the asymmetric path, which applies no scaling), across sizes 1 / 7 / 63 / 256 / 1000. Non-finite output where the reference is finite fails the test; the last-bit difference count and the worst relative error are printed.
  • git diff against the base touches five files: the kernel, the CoreFunctions slot, its declaration and registration, the dispatch site in CPUConvolution.cpp, and the test.

Note for whoever merges: this PR and the sibling add one slot to the CoreFunctions table in CommonOptFunction.h and register it in CommonOptFunction.cpp, at the same place in that block, so git reports a conflict in those two files if the other one lands first. It is an adjacency conflict -- both sides add a line, neither removes one -- and resolving it is a matter of keeping both additions. The test files have distinct names on purpose, so they never collide.

MNNConvInt8ComputeBiasFloat converts the int32 conv bias to float as
bias * weightScale * inputScale / outputScale. outputScale is loop-invariant,
so the per-element division is replaced by a reciprocal computed once outside
the loop, which is what the scalar loop's cost is dominated by.

Measured on the board (riscv64, VLEN=128, GCC 14.3.1, standalone kernel
harness, baseline built for rv64gc with zero vector instructions): 9.33x over
the scalar loop at size 4096, against 0.96x for the same kernel with the
division kept in the loop.

The reciprocal changes the last bit on part of the inputs -- t * (1.0f / os)
is not always the correctly rounded t / os. On realistic quantisation scale
pairs it differs on 24-33% of elements by 1 ULP, max relative error 6.2e-06.
A guard hoisted out of the loop falls back to the division when
1.0f / outputScale is not a finite normal number, which is the case that would
otherwise turn a subnormal outputScale into a non-finite result.

Co-authored-by: lyd1992 <liuyudong@iscas.ac.cn>
Co-authored-by: YuanSheng <yuansheng@isrc.iscas.ac.cn>
@wangzhaode

Copy link
Copy Markdown
Collaborator

Thanks for the optimization and for documenting the numerical trade-off! Could you please make two small changes to the registered test?

  1. It currently reports numerical differences but only fails for unexpected NaN/Inf. An incorrect finite result would still pass. Please add a reasonable absolute/relative error bound against the scalar reference and fail when it is exceeded. Bit-exact equality is not required for this reciprocal variant.
  2. Please skip successfully when runtime supportRVV is false and check the function-table entry before calling it. The build-time MNN_USE_RVV flag alone does not guarantee that this function pointer is populated.

As this and #4893 are alternatives for the same optimization, please clarify which implementation you recommend for merging. No extensive new benchmark suite is needed for these test adjustments. Thanks!

@wangzhaode wangzhaode added the awaiting contributor Waiting for contributor to address review comments or rebase label Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting contributor Waiting for contributor to address review comments or rebase module:quantization 量化相关 type:performance 性能问题

2 participants