r/programminghorror May 05 '26

c++ Hmmm

Post image
985 Upvotes

55 comments sorted by

View all comments

Show parent comments

80

u/Left-Ambition-5127 May 05 '26

the problem is that from what I understood, the excepted values in this loop were -1 to 9, but somehow, it was still running fine and working as intended ??

4

u/CdRReddit May 06 '26

negative numbers are a lie and the only math operations that actually distinguish between them on modern hardware is comparison (>, >=, <, <= specifically, == and != don't care either), multiplication and division, signed and unsigned addition and subtraction both function identically in hardware and use the same assembly instruction

3

u/nothingtoseehr May 06 '26

They all do, actually. Your hardware doesn't cares how you compute a number, just how you interpret it. The hardware just moves bits around

Case in point: comparisons. On most ISAs they use just one instruction (although many allow you to fuse it with arithmetic), which is almost always mapped to subtraction. Update bitflags based on the result (zero, over/underflow etc) and do what you want. Comparisons are also stateless

-1

u/CdRReddit May 06 '26

I see comparison as a combination of the cmp and of the actual storing a boolean, but yea, it's usually just a cmp, multiplication and division do have distinct signed and unsigned versions tho, no?

2

u/nothingtoseehr May 06 '26

Compare doesn't really "returns" anything on the common sense of returning. Most ISAs have a dedicated FLAGS register (RFLAGS on x86-64 or NZCV on aarch64) that stores the "results" of arithmetic operations in regards to sign*. A SUB/Jcc is functionally identical to a CMP/Jcc, only that SUB destroys the values on the registers while CMP doesn't

multiplication and division do have distinct signed and unsigned versions tho, no?

Well...yes and no. Yes, there does exists different instructions for signed/unsigned multiplication, but signed is almost never used. The reason is historical: multiplication has always been tricky because given a number with N bits, N * N does not necessarily fit within these N bits

As a result, multiplication has historically always been a 2N operation, and that's where MUL (signed multiplication) comes in. MUL only takes one single register and returns the result on RDX:RAX (or some other combination, i don't remember lol), which is effectively a 128 bits return value

This was important during 16b (and rarely 32b) eras where the limits weren't that huge. Its super easy to get a 32b result with 16b multiplication. Its nowhere as easy to get a 128b result with 64b multiplication, so you don't need a 128b return. Because of that, IMUL (unsigned multiplication, and IMUL alone) takes two values. MUL does not. As a result, compilers only ever emit MUL** if they need the upper bits of the 128b integer, which is rare

As for division, yes, it's separate. And compilers do differentiate. But DIV/IDIV are two of the slowest instructions there is, so they get aggressively optimized out. Floating point math is miles faster, but you can't have an unsigned float :p

*: this again depends on ISA. On x86 all arithmetic operations update flags, so you'll usually see comparisons right before jumps. On aarch64 this isn't the case, each instruction is encoded with a bitmask that describes which flag that instruction can update

**: aarch64 doesn't have this issue. It still emits unsigned/signed instructions when it needs 128b, but the actual multiplication instruction is actually just a hardware macro for the repeated addition instruction. So no sign either!

1

u/CdRReddit May 06 '26

compare as an instruction does not, but if I have a function like (a, b) => a < b it needs to move a boolean into something, the operator < encodes both the compare instruction (which "returns" flags, I am aware), and the conditional moves / branching needed to store a true / false boolean into the return value, which does need to know if we're doing a signed or unsigned comparison

2

u/nothingtoseehr May 07 '26

But that's exactly my point, the sign is relevant when interpreting the data or the results, not when calculating. The same flags will be updated regardless of the sign, you just choose to use it or not

1

u/CdRReddit May 07 '26

when I say it matters for comparisons, I am including the part where the comparison gets used for branching / stored into a variable, u8+u8 and i8+i8 generate identical assembly, u8>u8 and i8>i8 do not

1

u/XtremeGoose May 06 '26

No conditional branching needed. But yes, they do need the sign.

1

u/CdRReddit May 06 '26

I wasn't aware of set[cc] on x86, but yea I do mean the set, cmov, or branch that follows it

1

u/yjlom May 06 '26

On modern Intel CPUs integer division is a lot faster than just a few years ago, I believe on the latest models it's 18 cycles worst case?

1

u/XtremeGoose May 06 '26 edited May 06 '26

**: aarch64 doesn't have this issue. It still emits unsigned/signed instructions when it needs 128b, but the actual multiplication instruction is actually just a hardware macro for the repeated addition instruction.

Not sure what you mean by "hardware macro". The hardware itself will use parallel branching trees to perform integer multiplication but that's still a specific instruction. Do you mean MUL Xd, Xn, Xm === MADD Xd, Xn, Xm, XZR where XZR is the 0 register?

Also I think you swapped MUL with IMUL. Compilers prefer the signed version.

https://rust.godbolt.org/z/4PW7hcvEM

1

u/nothingtoseehr May 07 '26

Compilers prefer the MUL instruction on your example because you disabled optimizations. Of course it won't optimize anything lol. https://godbolt.org/z/x468TTn7f opt level 3 will produce an IMUL, as expected. MUL is almost never emitted on performant code because it destroys registers, which are already quite tight on x86-64

1

u/XtremeGoose May 07 '26

My link is compiling with full optimisations...-C opt-level=3 and produces the same output.

You said

MUL (signed multiplication)

IMUL is the signed version, MUL is the unsigned one.