Skip to content

Rounding operators #817

Open
Open
@fdwr

Description

@fdwr

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 than RHE or other modes to avoid staggered/asymmetric pattern artifacts (WebNN's resample uses RHD internally). I'm not proposing those though.
  • Javascript's Math.round is oddly aberrant, being neither RHE or RHAZ (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

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.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions