If you have a CPU with efficient SIMD instructions, SSE/MMX paddb
(_mm_add_epi8
) is also viable. Peter Cordes’ answer also describes GNU C (gcc/clang) vector syntax, and safety for strict-aliasing UB. I strongly encourage reviewing that answer as well.
Doing it yourself with uint64_t
is fully portable, but still requires care to avoid alignment problems and strict-aliasing UB when accessing a uint8_t
array with a uint64_t*
. You left that part out of the question by starting with your data in a uint64_t
already, but for GNU C a may_alias
typedef solves the problem (see Peter’s answer for that or memcpy
).
Otherwise you could allocate / declare your data as uint64_t
and access it via uint8_t*
when you want individual bytes. unsigned char*
is allowed to alias anything so that sidesteps the problem for the specific case of 8-bit elements. (If uint8_t
exists at all, it’s probably safe to assume it’s an unsigned char
.)
Note that this is a change from a prior incorrect algorithm (see revision history).
This is possible without looping for arbitrary subtraction, and gets more efficient for a known constant like 1
in each byte. The main trick is to prevent carry-out from each byte by setting the high bit, then correct the subtraction result.
We are going to slightly optimize the subtraction technique given here. They define:
SWAR sub z = x - y z = ((x | H) - (y &~H)) ^ ((x ^~y) & H)
with H
defined as 0x8080808080808080U
(i.e. the MSBs of each packed integer). For a decrement, y
is 0x0101010101010101U
.
We know that y
has all of its MSBs clear, so we can skip one of the mask steps (i.e. y & ~H
is the same as y
in our case). The calculation proceeds as follows:
- We set the MSBs of each component of
x
to 1, so that a borrow cannot propagate past the MSB to the next component. Call this the adjusted input. - We subtract 1 from each component, by subtracting
0x01010101010101
from the corrected input. This does not cause inter-component borrows thanks to step 1. Call this the adjusted output. - We need to now correct the MSB of the result. We xor the adjusted output with the inverted MSBs of the original input to finish fixing up the result.
The operation can be written as:
#define U64MASK 0x0101010101010101U
#define MSBON 0x8080808080808080U
uint64_t decEach(uint64_t i){
return ((i | MSBON) - U64MASK) ^ ((i ^ MSBON) & MSBON);
}
Preferably, this is inlined by the compiler (use compiler directives to force this), or the expression is written inline as part of another function.
Testcases:
in: 0000000000000000
out: ffffffffffffffff
in: f200000015000013
out: f1ffffff14ffff12
in: 0000000000000100
out: ffffffffffff00ff
in: 808080807f7f7f7f
out: 7f7f7f7f7e7e7e7e
in: 0101010101010101
out: 0000000000000000
Performance details
Here’s the x86_64 assembly for a single invocation of the function. For better performance it should be inlined with the hope that the constants can live in a register as long as possible. In a tight loop where the constants live in a register, the actual decrement takes five instructions: or+not+and+add+xor after optimization. I don’t see alternatives that would beat the compiler’s optimization.
uint64t[rax] decEach(rcx):
movabs rcx, -9187201950435737472
mov rdx, rdi
or rdx, rcx
movabs rax, -72340172838076673
add rax, rdx
and rdi, rcx
xor rdi, rcx
xor rax, rdi
ret
With some IACA testing of the following snippet:
// Repeat the SWAR dec in a loop as a microbenchmark
uint64_t perftest(uint64_t dummyArg){
uint64_t dummyCounter = 0;
uint64_t i = 0x74656a6d27080100U; // another dummy value.
while(i ^ dummyArg) {
IACA_START
uint64_t naive = i - U64MASK;
i = naive + ((i ^ naive ^ U64MASK) & U64MASK);
dummyCounter++;
}
IACA_END
return dummyCounter;
}
we can show that on a Skylake machine, performing the decrement, xor, and compare+jump can be performed at just under 5 cycles per iteration:
Throughput Analysis Report
--------------------------
Block Throughput: 4.96 Cycles Throughput Bottleneck: Backend
Loop Count: 26
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
| Port | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | 6 | 7 |
--------------------------------------------------------------------------------------------------
| Cycles | 1.5 0.0 | 1.5 | 0.0 0.0 | 0.0 0.0 | 0.0 | 1.5 | 1.5 | 0.0 |
--------------------------------------------------------------------------------------------------
(Of course, on x86-64 you’d just load or movq
into an XMM reg for paddb
, so it might be more interesting to look at how it compiles for an ISA like RISC-V.)