Division by constant signed integers
The code accompanying this article can be found in a github repository.
Division is a relatively slow operation. When the divisor is constant, the division can be optimized significantly. In [1] I explored how this can be done for unsigned integers. In this follow-up article, I cover how we can optimize division by constant signed integers. This article should be read as a continuation of [1]. As far as I know, the information in this article was first presented in [2].
I assume that like in most programming languages, the result of the signed division is rounded toward zero. This presents some challenges which are different than those for optimizing unsigned division. We are only dealing with numbers with a magnitude of at most , and we will see that this means we can always use the round-up method as described in [1]. The challenge consists of efficiently rounding up the quotient when is negative.
Mathematical background
Preliminaries
I will assume that we are working on an -bit machine which can efficiently compute the full -bit product of two -bit signed integers. I will use the notation for the set of unsigned integers that can be represented with bits:
Likewise, I will use the notation for the set of signed integers that can be represented with bits:
When and are sets, the set denotes the set of elements of that are not in .
For some real number , I will denote the absolute value of by . That is:
I will use the notation for the biggest integer smaller than or equal to , and for the smallest integer bigger than or equal to . I will use to denote the value of when rounded toward zero. That is
I will use the notation for the sign function:
Finally, I will use the notation where is a predicate to denote the characteristic function:
Signed division
To simplify things, I will assume that the divisor is positive. When is negative, we can use . This means that to compute we can simply compute the quotient and negate it when is negative.
So, assuming that is positive, we want to evaluate
Since the rounding is defined separately for negative and nonnegative dividends, it is natural to look at these cases separately.
When is nonnegative this is essentially an unsigned division, since we assumed that is positive. Since is a nonnegative -bit signed, we know that the most significant bit will be zero. So can be represented as an -bit unsigned integer. With the results from [1], it is straightforward to find an expression that can be efficiently evaluated and equals when and are positive.
Corollary 16: Let with , , and . Then and
for every nonnegative .
Proof: Observe that if is nonnegative and implies that . The result now follows by replacing by and by in theorem 4.
We will now take this same expression and consider what happens when is negative. We will proceed by proving results that are analogues for the unsigned case.
The following lemma is a result complementary to lemma 1.
Lemma 17: Let , and . When
then
Proof: Set with , . Then . Now and so that . So . Since is an integer, it follows that .
The following result is complementary to corollary 16.
Theorem 18: Let . If
then
for all negative .
Proof: Multiply by . Remember that is negative, so the inequality ‘flips’ and we get . Now, using we see that , so we have . The result now follows from lemma 17.
This theorem allows us to prove an analogue of theorem 3 for signed numbers.
Lemma 19: Let with , , and . Then
for every negative .
Proof: Since is just rounded up to the nearest multiple of , we have . We also have . Multiplying by and adding gives . So we have . By theorem 18, it follows that for all negative .
With these results, it is easy to derive an expression which equals the rounded quotient without the restriction that is positive. The following theorem is the main result of this article.
Theorem 20: Let and be integers with and define and . Then
for all .
Proof: First, observe that is simply the first multiple of larger than . Since there are , there must be at least one multiple of in the range . So we have . Using corollary 11 we see that for nonnegative . Using lemma 13, we see that for negative . So for all . Using the result follows.
Note: While this theorem seems like everything you need for implementation, there is a subtle detail sweeped under the rug. The product is a -bit product of an -bit unsigned number and an -bit signed number. Most processors do not have support for this operation. The evaluation of this product is considered in the section about implementation.
In [1], we saw that there exists a simple trick to reduce the ‘magic constant’ . This same trick works for signed division as well.
Theorem 21: Let , with , and let be such that . Suppose that the tuple satisfies the condition of theorem 18:
If is even, then the tuple satisfies the same condition. If is odd, then there exists no smaller that satisfies the condition.
Proof: Suppose that satisfies the condition of theorem 18. In this case, we have . It is easy to see that when is even all expressions in the inequality are even, so we can divide by two and see that . The case for the condition of lemma 5 is analogous.
Suppose that there is a smaller pair that satisfies the condition . By multiplying the whole thing by , we see that . The set has elements. We have , so there can only be one multiple of in this set, which is . So we have , so must be even.
Implementation
In this section, I use the uint
and sint
datatypes, which are an -bit unsigned integer and an -bit signed integer, respectively. I try to provide a general strategy that should work well on most instruction set architectures. Variations in the implementation might give a more efficient result. In general, you should always benchmark your implementation if performance is critical.
While theorem 3 in the previous section seems to provide a straightforward method to compute the quotient for any , there is one subtlety we glanced over. In theorem 4, we use the -bit expression , where is an unsigned value and is a signed value. While most processors have instructions to compute the full -bit product of two -bit unsigned integers or two -bit signed integers, most processors do not provide an instruction to compute the -bit product of an -bit unsigned integer and an -bit signed integer.
While it is also possible to compute the product by first extending and to -bit signed values and computing the product of those extended values, this is less efficient.
Computing the product of an unsigned and a signed value
In this section, consider and to be -bit bit strings. So these variables no longer represent a number, but purely a series of bits, which can hold a zero or a one. So we write , where are the individual bits.
Now, we can provide a string with a value when we interpret it as either an unsigned value , or as a signed value . These interpretations are defined as
and
We see that when and when . So when we have
So in this case we can just use signed multiplication. When we have
So, the upper bits of the product equals . This expression can be evaluated by multiplying and as if they where signed numbers, taking the upper bits, and adding to this.
Runtime optimization
Consider the following code:
sint d = read_divisor();
for (int i = 0; i < size; i++) {
quotient[i] = dividend[i] / d;
}
The value of the divisor d
is not known at compile time, but once it is read at runtime, it does not change. As such, we consider d
to be a runtime constant, and we can optimize this code in the following way:
sint d = read_divisor();
divdata_t divisor_data = precompute(divisor);
for (int i = 0; i < size; i++) {
quotient[i] = fast_divide(dividend[i], divisor_data);
}
Now, the divdata_t
datatype needs to hold , the number of bits to shift, and some field to indicate that we should negate the result when is negative:
typedef struct {
uint mul;
uint shift;
bool negative;
} divdata_t;
Now, we compute such that always has the most significant bit set. This way we can always compute the upper bits of the product in fast_divide
by taking the signed product, taking the upper bit, and adding to this.
The precomputation is now a relatively straightforward implementation of theorem 4:
sdivdata_t precompute(sint d) {
sdivdata_t divdata;
uint d_abs = abs(d);
// Compute ceil(log2(d_abs))
uint l = floor_log2(d_abs);
if ((1 << l) < d_abs) l++;
// Handle case |d| = 1
if (dabs == 1) l = 1;
// Compute m = floor(2^(N - 1 + l) / d) + 1
uint m = (((big_uint)1) << (N - 1 + l)) / d_abs + 1;
divdata.mul = m;
divdata.negative = d < 0;
divdata.shift = l - 1;
return divdata;
}
It should be noted that in the fast_divide
function, the right shift by is implemented by taking the upper bits of the product , and shifting this right by bits. If this is not possible, since in this case we need to shift right by bits, but taking the upper bits is already equivalent to a right shift of bits. We can fix this by simply setting when . In this case the expression for becomes , which will overflow to simply . Now, while theorem 4 doesn’t hold anymore for , the calculation of the product in fast_divide
assumes that the most significant bit of is set, so we will end up with the correct value. In fact, setting would work as well.
The fast_divide
function has a lot of steps, but every step should be understandable.
sint fast_divide(sint n, sdivdata_t dd) {
big_sint full_signed_product = ((big_sint)n) * (sint)dd.mul;
sint high_word_of_signed_product = full_signed_product >> N;
sint high_word_of_unsigned_product = high_word_of_signed_product + n;
sint rounded_down_quotient = high_word_of_unsigned_product >> dd.shift;
sint quotient_rounded_toward_zero = rounded_down_quotient - (n >> (N - 1));
if (dd.negative) {
quotient_rounded_toward_zero = -quotient_rounded_toward_zero;
}
return quotient_rounded_toward_zero;
}
Compile-time optimization
In this section, I will consider how to generate optimized code for division by compile-time constant signed integers.
Most of the tricks that are applicable to calculate a quotient of unsigned integers efficiently also apply to signed integers, although we might have to do some work to handle negative integers. Of course, a division by one can be ignored and a division by minus one is equivalent to a negation. For some instruction-set architectures, it might be beneficial to implement a special case for big divisors with an absolute value of more than . In this case, the value of the quotient is when and zero otherwise.
expression_t div_by_const_sint(const sint d, expression_t n) {
if (d == 1) return n;
if (d == -1) return neg(n);
uint d_abs = abs(d);
if (is_power_of_two(d_abs)) return div_by_const_signed_power_of_two(n, d);
return div_fixpoint(d, n);
}
Let us first consider the case where is a power of two. If we do an arithmetic right shift by bits, the result will be correct when is positive. However, this will round down the quotient when is negative. In this case we can add to in order to round up.
So, we would like to have an expression which equals when is negative and otherwise, so we can simply add this to . The value consists of consecutive ones in the binary representation. It can be created by doing an arithmetic right shift by bits on , and shifting the result right by bits (with a normal right shift). The first shift produces the ones in the most significant bits when is negative (these are zero bits otherwise), the second shift puts them in the least significant bit positions.
expression_t div_by_const_signed_power_of_two(expression_t n, sint d) {
uint d_abs = abs(d);
int l = floor_log2(d_abs);
// addme equals 2^l - 1 when n is negative and 0 otherwise
// We need to add this to n to round towards zero.
expression_t addme = shr(sar(n, constant(l - 1)), constant(N - l));
expression_t result = sar(add(n, addme), constant(l));
if (d < 0) result = neg(result);
return result;
}
References
[1] Division by constant unsigned integers, Ruben van Nieuwpoort, 2020.
[2] Division by Invariant Integers using Multiplication, Torbjörn Granlund and Peter L. Montgomery, 1994.