https://pushbx.org/ecm/doc/insref.htm is a corrected + improved version of the appendix of the NASM manual that documents the CPU required for each form of each instruction, along with English descriptions of them. e.g. that shl r/m8, imm8
was new in [186], with [8086] only having shift by 1 or by CL. Current versions of the NASM manual stripped out the English descriptions of instructions as it got too long with new SIMD instructions.
The simple way is to just put the count in cl
and use shl al, cl
. Or for counts of 2
, just repeat shift-by-1 twice. Maybe even for a count of 3
, although 3x shl al,1
(2 bytes each) costs more code-size than mov/shl (4 bytes total) so could be slower depending on surrounding instructions. (Variable-count shifts are very slow on 8086, though, so slow that the prefetch buffer will fill up.)
Emulating shl al, 6
has room for being clever, like maybe rotate right by 2 and mask, instead of shifting left by 6. (SAL and SHL are the same instruction. Many people prefer to always write SHL regardless of the signedness of the operand.)
Generally avoid mul
and imul
for constants. They're very slow on 8086/8088, and a constant can be broken down into its set bits like x*40 = x*32 + x*8 = (x*4 + x)*8
, which you do with shifts and adds. Or x*15
= x*16 - x
. Never use imul
for small power-of-2 constants like 4
, that's what shifts are for. (Even if you need to widen the result to 16 or 32-bit, prefer shifts for powers of 2.) If you do use a hardware multiply, use mul r8
unless you need the high half of the result to be signed. imul
is slower on 8086.
Shift-and-add is what compilers (like GCC) will do with -m32 -mtune=i386
even when it takes much more than 2 instructions, although you'd need to stop GCC from using 32-bit LEA addressing modes for its shifts and adds if you want it to help you figure out a good sequence of shifts and adds. Perhaps AVR GCC, since AVR also only has 1-bit shifts. For example on Godbolt multiplying by 40 using ((x<<2)+x) << 3
. Or by 14 using ((x<<3)-x) << 1
. (For 8086, you can decide when it's worthwhile to put a count into cl
instead of repeating shift instructions, since that's an option AVR doesn't have.)
Fun fact: 8086 does have an immediate multiply, AAD imm8
which does AX = (AL + AH*imm8) & 0xFF
. But it's not fast (60 cycles), and some non-Intel CPUs ignore the immediate and assume it's 10.
If you're optimizing for performance, that's often the same thing as optimizing for code-size on 8088, since code-fetch is the major bottleneck. (1 byte per 4 cycles, half the bus-width of 8086.) 8088's prefetch queue is 4 bytes, vs. 8086's being 6 bytes.
The prefetch queue is discarded on a taken branch (there's no branch prediction), so that's probably part of why they made it shorter on 8088. (But it still takes 4 fetches to fill it, vs. 3 on 8086.)
See also
Supercat's point that counting memory accesses (including code fetch) is usually the best way to predict performance on 8088, except around very slow instructions like mul
that would let the prefetch buffer fill and leave memory untouched for many cycles.
This is what makes x86's 1-byte xchg ax, reg
useful sometimes instead of mov ax, reg
or mov reg, ax
, if you just need a value somewhere else and don't need to keep a copy. And optimizing to have values in AX or AL for the short-form encodings like 2-byte add al, imm8
. And inc cx
is 1 byte, vs. 2 bytes for inc cl
, so use 16-bit inc
when possible even if you only care about the low 8 bits.
https://www2.math.uni-wuppertal.de/~fpf/Uebungen/GdR-SS02/opcode_i.html instruction timings for 8088 through Pentium, but not including code-fetch costs. i.e. cycles when the instruction is already prefetched.
mov reg, imm8
- 4
cycles (But it's a 2 byte instruction so it costs 8 cycles of code-fetch.)
shl reg, 1
- 2
cycles (code size = 2 bytes)
shl reg, cl
- 8 + 4*count
cycles. (code size 2 bytes)
mul r8
- 70 to 77 vs. imul r8
80-98
mul r16
- 118-133 vs. imul r16
128-154
- (So if you don't need the high-half result, use
mul
not imul
. The product bits out to the width of the inputs, not overlapping with any sign or zero-extension bits, is the same for mul and imul.)
Increasing Efficiency of binary -> gray code for 8086 on Stack Overflow, putting some of this into practice for the int->hex part of the question.
Shifting by more than half a register
mov cl, 6
and a slow shl al, cl
will give the prefetch buffer time to fill (up to 4 bytes) while the shift is running. But the shift costs 8+4*6 = 32
cycles, time for 8 bytes of memory fetches. After refilling the prefetch buffer (4 fetches taking 16 clock cycles), that leaves another 4 fetch cycles wasted. So the prefetch buffer was only able to hide half the cost of the shift.
If you're counting in memory bytes, count that shl al,cl
as 2 bytes (to fetch itself) plus 4 bytes of lost/wasted fetch time, assuming later instructions are fast and not a branch, so they consume those 4 bytes of prefetch before more cycles are wasted. So the total cost of mov cl,6
/ shl al,cl
is equivalent to at least 8 bytes of instruction fetch, if it started with the buffer empty and usefully ended with it full.
; emulate shl al, 6
and al, 3 ; 2 bytes, 4 cycles
ror al, 1 ; 2 bytes, 2 cycles
ror al, 1 ; 2 bytes, 2 cycles
(Or rotate first and end with and al, 0xc0
, i.e. (0xff<<6) & 0xff
. I don't think this will matter, since even the slower instruction is still as fast as a memory access. Even if this is preceded by slow instructions so it starts with the prefetch buffer full, it will leave the prefetch buffer empty when it finishes. Unless AND-immediate takes more cycles before it frees space in the prefetch buffer, in case it was full when this started decoding.)
This is more actual code size than mov cl,6
/ shl al, cl
but faster even considering the prefetch buffer: only its actual 6 code-fetch bytes, no lost code-fetch cycles of prefetch-buffer overflow.
I also considered mov ah, al
+ mov al, 0
/ 2x shr ax, 1
. Or maybe xchg ah, al
if AH had already been zero. But rotate is better if you don't have a use for the x >> 2
value in AH.
The same analysis method applies for mov cl, 40
/ mul cl
vs. multiple mov
/ shl
/ add
instructions, but mul cl
is so slow you have a lot more room for more instructions while still coming out ahead. (70-16)/4 is 13.5, and that's not counting the 4 bytes for mov+mul themselves. Even moreso for 16-bit multiplies which take a minimum of 118 cycles. (118-16)/4 is 25.5, and you'd need 3-byte mov dx, 40
to set up for it. DX is a good temporary since mul r16
produces a result in DX:AX.)
For 16-bit shifts by 8 or more, start with mov ah, al
/ mov al, 0
for left shifts.
Xor-zeroing isn't particularly useful for 8-bit registers: same code size unlike for 16-bit registers. mov al, 0
is 4 cycles vs. 3 for xor al,al
on 8086, though, so it could matter if the prefetch buffer might overflow due to slow instructions before and after.
But xor ah,ah
is slower than mov ah,0
on some modern CPUs (like Intel Haswell and later). Of course, xor ax, ax
is better than mov ax, 0
, saving a byte of code-size, so always use it for 16-bit registers. (Modern CPUs don't special-case it as a zeroing idiom though, for registers narrower than 32-bit.)
Intel P6 family (Pentium Pro through Nehalem) did handle xor ax,ax
as a 16-bit zeroing idiom. uops.info throughput test results for Core 2 show xor r8w,r8w
averaging 0.33 cycles per instruction, vs. 1 cycle for xor r8w,r9w
(bottlenecked on latency; later tests with multiple independent XORs in the loop show it can run them at 3/clock as well). (Despite Core 2's front-end being 4 uops wide, it still needed a back-end uop to actually write a zero to a register, unlike on Sandbybridge-family where zeroing idioms are eliminated in the issue/rename stage.)
P6 renamed the low 16-bit register separately from the full register. Unlike first-gen Sandybridge which renamed 8-bit (but not 16-bit) partial registers, and later Sandybridge which only renames AH/BH/CH/DH separately from the full registers.
If you want a shifted copy of a register, you might start with xor dx,dx
/ mov dh, al
/ shl dx,1
to do dx = ax<<9
. In that case, xor-zeroing does indeed work well, at least as good as mov dl, 0
/ mov dh, al
on most CPUs, and better on P6-family. (Writing DH and then shifting DX still causes a partial-register stall on P6-family CPUs, though.)
If all else is equal in terms of 8086, you might want to consider modern CPUs that might also run your code when choosing instructions.