Description
Summary
While writing out the emulation decomposition for quantizeLinear
, I lacked rounding functions in WebNN, particularly the default IEEE standard round-tied-halves-to-nearest-even. There's not a nice way to simulate this missing function from existing WebNN operators, and it's a basic primitive that belongs in a library anyway. So I propose filling in some gaps ⭐:
- ➕ Add
roundEven()
which is the IEEE standard's default, and relevant ML libraries support it (see support below). - ➕ Document
trunc
via emulation at least. I originally thought it was worth adding, but I haven't actually seen it in models, and evidently only DML ROUND and PyTorch torch.trunc have it, not TFLite/CoreML/ONNX. There are emulations below.
Known Rounding Modes
Rounding mode | WebNN | IEEE | C++ equivalent | JS equivalent |
---|---|---|---|---|
RHE nearest int, halves to even ⭐ (banker's rounding) |
❌ | convertToIntegerTiesToEven(x) The recommended IEEE float default |
C23 roundeven std::nearbyint FE_TONEAREST |
❌😲?? |
RHAZ nearest int, halves away from zero |
❌ floor(abs(x) + 0.5) * sign(x) |
convertToIntegerTiesToAway(x) | std::round | ❌ |
RHTZ nearest int, halves toward zero |
❌ ceil(abs(x) - 0.5) * sign(x) |
❌ | ❌ | ❌ |
RHU nearest int, halves up |
❌ floor(x + 0.5) |
❌ | ❌ | Math.round |
RHD nearest int, halves down |
❌ ceil(x - 0.5) |
❌ | ❌ | ❌ |
RAZ away from zero |
❌ ceil(abs(x) * sign(x)) |
❌ | ❌ | ❌ |
RTZ toward zero (trunc) ⭐ |
❌ floor(abs(x) * sign(x)) |
convertToIntegerTowardZero(x) | std::trunc std::nearbyint FE_TOWARDZERO |
Math.trunc |
RU up to positive infinity (ceil) |
✅ ceil(x) |
convertToIntegerTowardPositive(x) | std::ceil std::nearbyint FE_UPWARD |
Math.ceil |
RD down to negative infinity (floor) |
✅ floor(x) |
convertToIntegerTowardNegative(x) | std::floor std::nearbyint FE_DOWNWARD |
Math.floor |
Note
- Some of these modes are not generally useful, but they are very useful within their domain. For example,
RHD
is used often in graphics when rasterizing triangles or resampling images rather thanRHE
or other modes to avoid staggered/asymmetric pattern artifacts (WebNN'sresample
usesRHD
internally). I'm not proposing those though. - Javascript's
Math.round
is oddly aberrant, being neitherRHE
orRHAZ
(the two most common IEEE nearest modes). So beware when using it as a baseline reference. Also I'm using "up" (higher value toward positive infinity) and "down" (lower value toward negative infinity) like C's usage, not Java's bidirectional RoundingMode where up and down mean away from zero and toward zero.
Library support
RHE
nearest int, halves to even:- DML ROUND with DML_ROUNDING_MODE_HALVES_TO_NEAREST_EVEN
- coremltools.converters.mil.mil.ops.defs.iOS15.elementwise_unary.round 🤔 *It's not clearly
RHE
from the docs, but its single number example of 0.5 implies that it is, and by default, I presume the IEEE default. Otherwise it should be emulatable viaelementwise_binary.sub(x, elementwise_binary.mod(x, 1.0f))
. 🤞 - tfl.round (TFL::RoundOp) 🤔 *It's not clearly documented for TFLite, but I'm presuming TFL follows TF in using RHE.
- ONNX Round
- PyTorch torch.round
RTZ
toward zero (trunc):
Examples
Original value | -2.5 | -1.75 | -1.5 | -1.25 | 0 | 1.25 | 1.5 | 1.75 | 2.5 |
---|---|---|---|---|---|---|---|---|---|
RHE nearest int, halves to even |
-2 | -2 | -2 | -1 | 0 | 1 | 2 | 2 | 2 |
RHAZ nearest int, halves away from zero |
-3 | -2 | -2 | -1 | 0 | 1 | 2 | 2 | 3 |
RHTZ nearest int, halves toward zero |
-2 | -2 | -1 | -1 | 0 | 1 | 1 | 2 | 2 |
RHU nearest int, halves up |
-2 | -2 | -1 | -1 | 0 | 1 | 2 | 2 | 3 |
RHD nearest int, halves down |
-3 | -2 | -2 | -1 | 0 | 1 | 1 | 1 | 2 |
RAZ away from zero |
-3 | -2 | -2 | -2 | x | 2 | 2 | 2 | 3 |
RTZ toward zero (trunc) |
-2 | -1 | -1 | -1 | 0 | 1 | 1 | 1 | 2 |
RU up to positive infinity (ceil) |
-2 | -1 | -1 | -1 | 0 | 2 | 2 | 2 | 3 |
RD down to negative infinity (floor) |
-3 | -2 | -2 | -2 | 0 | 1 | 1 | 1 | 2 |
Emulation
RHE (roundEven)
A backend can emulate RHE
if it has modulus
/remainder
and trunc
, as x - std::remainder(x, static_cast<T>(1))
(source) (even though WebNN itself lacks both those operators).
Another odd approach that evidently works for float32 (but not as-is for float16/float64) uses a few condition checks with addition and subtraction, but that ends up being a clumsy construction in WebNN.
float roundHalvesToNearestEven(float f)
{
if (!(f > -8388608.0f && f < 8388608.0f)) // Return true for NaN
return f;
else if (f > 0)
return float(f + 8388608.0f) - 8388608.0f;
else
return float(f - 8388608.0f) + 8388608.0f;
}
// output = abs(input) < magicValue ? (abs(input) + magicValue - magicValue) * sign(input) : input
function roundEven(builder, input)
{
const magicValue = builder.const("float32", 8388608.0f);
const absInput = builder.abs(input);
builder.where(
builder.less(absInput, magicValue),
builder.mul(
build.sub(builder.add(absInput, magicValue), magicValue),
builder.sign(input)
),
input
);
}
RTZ (trunc)
// output = floor(abs(input)) * sign(input)
function trunc(builder, input)
{
return builder.mul(
builder.floor(builder.abs(input)),
builder.sign(input)
);
}
There's also an approximation using a round-trip cast
(float -> int -> float), but that's lossy with large numbers.
Models
- RHE:
- tiny-yolov3-11\yolov3-tiny.onnx
- QWNet
- RTZ
- ? (none personally known)
API
partial interface MLGraphBuilder {
MLOperand roundEven(MLOperand input, optional MLOperatorOptions options = {});
};
partial dictionary MLOpSupportLimits {
MLSingleInputSupportLimits roundEven;
};
operand | allowed data types | allowed ranks |
---|---|---|
input | "float32", "float16" | N |
output | same as input | same as input |
Naming: Why not just "round"? round
is woefully ambiguous as evidenced from the differences between C++ std::round
(RHAZ) vs Javascript/Java Math.round
(RHU) vs round
in others like HLSL (RHE) 😕. roundEven
(like C23 roundeven) is directly clear in the name.