Conversation
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>
Collaborator
|
Thanks for the optimization and for documenting the numerical trade-off! Could you please make two small changes to the registered test?
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! |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
One RVV kernel for CPU ConvInt8 resource initialisation, registered in the
CoreFunctionstable underMNN_USE_RVVwith a scalar fallback when the pointer is null (non-RISC-V and RVV=OFF builds keep the original scalar path).MNNConvInt8ComputeBiasFloatconverts the int32 conv bias to float asbias * weightScale * inputScale / outputScale.outputScaleis 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
bias-rcp)bias-mks)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 8single-core pinning. Kernel-only harness, no MNN build; every arm compared in the same run, min over 9 rounds with the arm order rotated.rv64gc, the code the kernel replacesobjdump -dconfirms the NoV object contains zero vector instructions, so the baseline is genuinely scalar.The unscaled path (
inputScaleoroutputScaleis 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:
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 roundedt / os, and this arm accepts that. The sibling PR takes the other side of that trade.Safety
1.0f / outputScaleoverflows toinfwhenoutputScaleis subnormal, which the original per-element division does not do. A check hoisted out of the loop fixes it:Without the guard, an
outputScaleof 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
test/backend/cpu/RVVConvInt8BiasRcpTest.cpp:(inputScale, outputScale)pairs including non-representable values, one pair withinputScale == 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 diffagainst the base touches five files: the kernel, theCoreFunctionsslot, its declaration and registration, the dispatch site inCPUConvolution.cpp, and the test.Note for whoever merges: this PR and the sibling add one slot to the
CoreFunctionstable inCommonOptFunction.hand register it inCommonOptFunction.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.