如何解决提高二进制的效率 -> 8086 的格雷码 8088 性能主要是关于内存访问包括代码获取比循环版本更慢、更大,但“更简单”
我是汇编的初学者,这是我设计用于从二进制转换为灰色的代码,并以十六进制打印生成的位模式。
mov al,a
mov bl,al
shr bl,1
xor al,bl
虽然程序可以运行,但我想学习其他更简单的方法来提高效率,我尝试了很多其他方法,但它影响了输出。
解决方法
(此答案是根据问题的第一个版本编写的,其中有一些不是最佳的十六进制打印代码,并且它是 .exe 程序的完整源代码。对问题的更新已删除除了与 8086 无关的 ILP 之外,只有那些有优化空间的部分,所以我不打算删除答案的那些部分。)
代码大小优化(与 8086 尤其是 8088 上的速度相关,请参阅 this retrocomputing answer):
- bin2Gray 部分:没有变化,除非您计算从 mem 重新加载或使
a
保持不变。只需在奔腾和更高版本上重新订购 ILP 的说明。或者也许是一张xlat
的桌子。 - 字节->十六进制数字:21 字节,低于 32 字节(以及更少的代码提取字节)
- exit:从 4 (mov ah / int) 减少 1 个字节 (
ret
)。至少适用于.com
的可执行文件,这些文件也更小。
我可能应该计算需要获取的总字节数(即执行的指令字节数)中的代码大小,而不是静态代码大小,尽管这对于优化也很有用。
从 21 字节 bin2hex 部分删除循环将花费几个字节的静态代码大小,但将动态字节数减少约 5,IIRC。并避免在采取的分支上丢弃任何预取缓冲区,当然 int 10h
除外。
int 20h
也可以退出(不需要任何 AH 设置),但只能从 .com
可执行文件退出。对我来说有趣的部分是用紧凑的代码计算所需寄存器中的 ASCII 数字,但如果你想要一个小的整体程序,.com
是要走的路。这也避免了 DS 的设置。 (尽管如果您使 a
和 EQU 或 =
保持不变,则不需要设置 DS。)
未尝试:利用初始寄存器值,这在某些 DOS 版本中显然是可靠的。如果您假装自己正在编写一个可能作为更大程序的一部分有用的块,那是不可行的。
你的程序基本上有两个独立的部分;计算格雷码,并为寄存器中的 1 字节值计算 bin->hex。单独提取半字节并不能有效地向后优化格雷码计算,因此我认为我们可以将它们完全分开。
有multiple different ways to make a Gray code(连续值之间只有一位翻转)。 AFAIK,x ^ (x>>1)
是从二进制计算最便宜的,但在给定寄存器中的输入的情况下,您可能只用 2 条指令就可以完成某些事情。
还相关:Gray code algorithm (32 bit or less) for gray->binary 指出标准 x ^ (x>>1)
是 GF(2k) 中的乘法。因此,在最近的带有 Galois-Field 指令的 CPU 上,我认为您可以使用 gf2p8affineqb
一次处理 16 个字节。 (gf2p8mulb
使用固定多项式,我认为这不是我们想要的。)
8088 性能主要是关于内存访问(包括代码获取)
https://www2.math.uni-wuppertal.de/~fpf/Uebungen/GdR-SS02/opcode_i.html 显示指令时序,但这些时序是 only execution,而不是代码提取。 8088 有一个 4 字节的预取缓冲区(并且只有一个 8 位数据总线),8086 上的 6 字节带有 16 位总线。 Supercat 在那里的回答中建议:
在原始 8088 处理器上,估计执行速度的最简单方法通常是忽略周期计数,而是计算内存访问(包括指令获取)并乘以 4。
我认为 8086 的情况大致相同,只是每次访问可以是一个完整的 2 字节字。所以直线代码(无分支)一次可以获取 2 个字节。
为简单起见,我只是用表中的指令大小和周期数注释了 asm 源代码,而没有尝试对预取缓冲区的行为进行建模。
xlat
(如 al = ds:[bx+al]
)只有 1 个字节,如果您不介意拥有一个 256 字节的表,则值得使用。执行需要 11 个字节,但这包括它进行的数据访问。不计算取码,mov bl,al
/ shr al,1
/ xor al,bl
是2+2+3个周期,但是3个字的代码大小需要12个周期来取。 xlat 几乎需要那么长时间,但是当它完成时,预取缓冲区将有一些时间来获取后面的指令,所以我认为它更胜一筹。
不过,它确实需要该表来自某个地方,或者是在加载可执行文件时来自磁盘,或者您必须预先计算它。而且你需要得到一个指向 BX 的指针,所以如果你能在循环中做到这一点,它可能只是一个胜利。
但是如果您使用的是表格,则可以将问题的两个部分结合起来,并为给定的格雷码查找两个 ASCII 十六进制数字字符二进制,例如mov dx,[bx + si]
表指针在 SI 中,二进制字节在 BL 中,BH=0。 (DX 设置您使用 DOS 调用输出 DL。)这当然需要您的表为 256 字(512 字节)。拥有一个很小的可执行文件可能比在这里节省几个周期更有价值;屏幕或文件的实际 I/O 可能足够慢,以至于无关紧要。但是,如果您要为多个字节执行此操作,则将 ASCII 字节对复制到缓冲区中可能会很好。
有一种优化可以帮助更现代的 CPU(从 Pentium 开始)可以并行运行 1 条以上指令:复制寄存器,然后移位原始,以便可以在相同的情况下发生循环作为副本。
; optimized for Instruction-level Parallelism
;; input: AL output: AL = bin_to_gray(AL)
;; clobbers: DL
mov dl,al ; 2B 2 cycles (not counting code-fetch bottlenecks)
shr al,1 ; 2B 2c
xor al,dl ; 2B 3c
(有关现代 CPU 的更多信息,请参阅 https://agner.org/optimize/。还有 Can x86's MOV really be "free"? Why can't I reproduce this at all? - mov-elimination 不适用于字节或字寄存器,因为它合并到 EDX 的低部分。所以即使在CPU 通常具有 mov-elimination,它不能在此处应用,因此此优化可节省延迟。)
我很确定 bin -> gray 没有进一步的改进空间。即使是现代 x86 也没有复制和右移(除了在另一个寄存器 BMI2 shrx
中的计数,或者对于带有 AVX 的 SIMD 寄存器,但仅限于 word/dword/qword 元素大小)。也没有右移和异或,所以没有避免 mov
,显然 shr 和 xor 也是必要的。 XOR 是加无进位,但我认为这没有帮助。除非您使用无进位乘法 (pclmulqdq
) 和乘法器常数来将输入的两个副本以正确的偏移量获取到乘法结果的高半部分,否则您将需要单独执行这些操作.或者使用 Galois-Field 新指令 (GFNI):What are the AVX-512 Galois-field-related instructions for?
不过,如果您想彻底检查,https://en.wikipedia.org/wiki/Superoptimization - 要求超级优化器寻找与 mov/shr/xor 序列产生相同 AL 结果的序列。
在实际用例中,您通常需要在寄存器中获取数据的代码,因为这是您将数据传递给函数的方式。在 mov al,a
之后,这就是您的代码所做的。
但是如果它是内存中的全局变量,则可以通过加载两次而不是用 mov
复制寄存器来节省一个字节的代码大小,但会牺牲速度。 或者更好的是,将其设置为汇编时间常数。(尽管如果这样做,下一步是在汇编时间进行计算。)
mov al,a ^ (a>>1)
字节 -> 2 个 ASCII 十六进制数字
这是更有趣的部分。
仅循环 2 次迭代有时是不值得的,尤其是当您对每一半做单独的事情时可以节省一些工作时。 (例如,低半字节是 ; a equ 0ACh ; makes the following instructions 2 bytes each
;;; otherwise,with a being static storage,loading from memory twice sucks
mov al,a
shr al,1 ; 2B,2 cycles
xor al,a ; reg,imm: 2B,4 cycles on 8088. reg,mem: 3B,13+6 cycles
,高字节是 x & 0xf
。使用 rol/mov/ 并不是最佳的。)
技巧:
-
首选
x >> 4
- x86 具有 short-form special cases for immediate operands with AL。 (还有 AX,imm16)。 -
想要在 AL 中做事意味着使用 BIOS
int 10h
/ AH=0Eh 电传输出打印更有效,该输出在 AL 中接受其输入,并且不会破坏任何其他寄存器。我认为 BIOS 输出会忽略像insn al,imm
这样的 DOS I/O 重定向并始终打印到屏幕上。 -
有一个邪恶的黑客滥用
DAS
将 0..15 整数转换为 ASCII 十六进制数字foo > outfile.txt
或'0'..'9'
而没有分支.在 8086 上(与现代 x86 不同)DAS 与典型的整数指令一样快。请参阅 this codegolf.SE answer 以了解其工作原理;它非常不明显,但它避免了分支,因此实际上在 8086 上有很大的加速。 -
BIOS/DOS 调用一般不会修改AH,所以设置可以在循环外完成。
-
然后对于代码大小,而不是仅仅展开,使用
'A'..'F'
作为循环计数器循环返回并重新运行一些早期代码一次(不是包括班次)。cl=4
/sub cl,2
会起作用,但使用奇偶校验标志是一种使用jnz
(1B) /dec cx
向后跳一次,然后下一次失败的方法。 -
DOS 程序(或至少
jpe
程序)的 SP 指向一些干净退出的代码的地址。因此您可以通过.com
退出。
(我没有考虑在保持整体策略的同时改进您的循环。对尽可能多的指令使用 ret
是可能的。值得,但运行 AL
两次而不是移动一次成本8086 上的很多周期:8 + 4*n 用于按 CL 移位。)
rol
然后您可以使用 ;; input: byte in AL. output: print 2 ASCII hex digits with BIOS int 10h
;; clobbers: CX,DX
hexprint_byte:
mov ah,0Eh ; BIOS teletype call #
; push ax ; 1B 15c
mov dx,ax ; 2B 2c ; save number,and AH=call number
mov cl,4 ; 2B 4c
shr al,cl ; 2B 8+4*4 cycles isolate the high nibble
.loop:
cmp al,10 ; 2B 4c set CF according to digit <= 9
sbb al,69h ; 2B 4c read CF,set CF and conditionally set AF
das ; 1B 4c magic,which happens to work
int 10h ; 2B BIOS teletype output (AL),no return value
; pop ax ; 1B 12c ; would do one extra pop if you used this instead of mov/xchg,so you'd need jmp ax instead of ret. But AND destroys AX
xchg ax,dx ; 1B 3c ; retrieve the original again (with AH=0Eh call number)
and al,0Fh ; 2B 4c ; isolate the low nibble this time
dec cx ; 1B 3c ; PF is set from the low byte only,CH garbage isn't a problem.
jpe .loop ; 2B 4c-not-taken,16c-taken
; 4-1 = 3,(0b11) which has even parity
; so JPE is taken the first time,falls through the 2nd
;; size = 21 bytes
或 ret
退出程序。
这是 NASM 语法;如果您的汇编程序不喜欢 int 20h
则将其更改为其他内容。 (NASM 不允许 .loop
作为本地标签,所以无论如何我都必须选择不同的名称。)我在 Linux 上测试了这个单步执行以确保循环分支被采用一次,并且我得到了正确的值在 AH/AL 中,当达到 2:
时。 (我用 NOP 替换了它,因为我实际上将它构建到一个 32 位静态可执行文件中,因此我可以轻松地在 GDB 中单步执行它,而不会弄乱过时的 16 位开发设置。字节数来自组装为 16-有点,当然。)
为了速度,复制 cmp/sbb/das/int 10h 只需多几个字节,节省了 int 10h
/dec
。 (像 7 个字节而不是 dec/jpe 的 3 个字节)。无论哪种方式,第一次打印后的 xchg / AND 都是必需的。
采用的分支需要 16 个周期,这将避免 jpe
/ xchg
(3 字节 / 7 个周期)的第二次冗余/无用执行以及循环开销。
您要求小(并且在 8086 上快速),所以这就是我所做的。这牺牲了其他一切,包括可读性,以节省字节。但这就是在汇编中打代码的乐趣!
不幸的是,它也绝对不是更简单,就像您在标题中所要求的那样。更简单可能使用查找表,也许使用 xlatb。这在 8086 上也可能更快,特别是如果您想避免 and
hack。
另一个可能有助于代码大小(但对性能非常不利)的技巧是 DAS
设置 AH= 商 = 前导数字,AL = 余数 = 尾随数字(低)。 (请注意,这与 aam 16
相反)Displaying Time in Assembly 显示了将其与 BIOS int 10h 输出一起用于 2 位 十进制 数字的示例。 (通常 AAM 与立即数 div bl
一起使用,显然 NEC V20 CPU 忽略立即数并始终除以 10。Intel CPU 只是对 AL 进行立即除法)。在 8088/8086 上,AAM 需要 83 个周期,类似于 10
,这基本上就是它的作用。使用 2 次幂的硬件除法通常很糟糕。
使用 AAM 16 的版本有 23 个字节,不使用任何循环(我在寄存器中没有任何常量可以利用,所以 div
/ mov cx,1
将是 5 个字节,而 cmp/sbb/das/int 10h 总共是 7 个)
比循环版本更慢、更大,但“更简单”
loop
我想知道是否可以将 aam 16 ; 83 cycles,2 bytes AH= quotient = leading digit AL = remainder = trailing digit (low)
; normally never use div or aam by a power of 2,only for code-size over speed.
cmp al,which happens to work
xchg dx,ax ; 1B 3c stash low digit in DL
mov al,dh ; 2B 2c get leading digit
cmp al,10 ; 2B 4c
sbb al,69h ; 2B 4c most significant (high) nibble as ASCII hex
das ; 1B 4c
mov ah,0Eh ; 2B 3c BIOS teletype output (of AL),advancing cursor
int 10h ; 2B ?
mov al,dl ; 2B 2c ; get low digit back from DL xchg ax,dx breaks AH callnum
int 10h ; 2B
; size=23B
与来自 DL 的输入一起用于输出之一?这将需要更改 AH,但也许可以为第二个输出完成。不幸的是,DOS 调用步骤 AL,将其设置为打印的字符。 (例如,使用这个 int 10h 调用)。
相关:
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。