【问题标题】:Fast signed 16-bit divide by 7 for 65026502 的快速有符号 16 位除以 7
【发布时间】:2018-07-18 21:30:31
【问题描述】:

我正在为一个 6502 cpu 编写一个汇编语言程序,我发现我需要一个尽可能快的除以七的例程,特别是一个可能需要 16 位除数的例程。

我熟悉here 找到的例程,但是推广那里发现的除以七的例程相当复杂,粗略检查一下通用算法(使用整数除法)

x/7 ~= (x + x/8 + x/64 ... )/8

表示要处理 16 位范围,可能需要超过 100 个周期才能完成,因为 6502 的单个累加器寄存器以及 6502 上单个内存位移的相对缓慢。

我认为查找表可能会有所帮助,但在 6502 上,我当然仅限于 256 字节或更少字节的查找表。为此,可以假设存在两个 256 字节查找表,xdiv7 和 xmod7,当使用无符号的单字节值作为表中的索引时,可以快速获得一个字节除以 7 或取模的结果7,分别。但是,我不确定如何利用这些来找到完整 16 位范围的值。

同时,我还需要一个模 7 算法,尽管理想情况下,可以通过除法计算出的任何解决方案也将产生一个 mod7 结果。如果需要额外的预计算表,我可以添加这些,只要所有表的总内存需求不超过 3k 左右。

虽然我最终需要一个有符号的除法算法,但一个无符号的就足够了,因为我可以根据需要将其推广到一个有符号的例程。

任何帮助将不胜感激。

【问题讨论】:

  • 我对 6502 不熟悉,但乍一看,好像也没有乘数?如果您可以有效地进行 16 位 x 16 位 -> 32 位乘法运算,那么除以 7 既简单又快速。
  • 除此之外,您已经提到的“添加 3 位组”算法可能是最快的非 LUT 方法。没有高效的变速杆将是一个巨大的障碍。
  • 100 个周期实际上听起来并不那么糟糕。 8 位除法在 8086 CPU 上超过 100 个周期。
  • 是的,尽管我希望我可以使用表格查找和一些附加功能。 6502 位移每位移位有成本,而且它本身的成本几乎是添加的两倍,除非数据已经在累加器中。
  • 可以使用查找表来完成,但它需要 5 个 256 字节的表和 2 个 13 字节的表。生成的程序集包含 7 个表查找和 4 个添加指令。如果您有兴趣,我将发布生成查找表的 C 代码,并演示除以 7。

标签: assembly division micro-optimization integer-division 6502


【解决方案1】:

注意:正如 @Damien_The_Unbeliever 在 cmets 中指出的那样,upperHighlowerLow 表是相同的。所以它们可以组合成一个表。但是,这种优化会使代码更难阅读,解释也更难写,因此将表格组合起来留给读者作为练习。


下面的代码显示了如何在将 16 位无符号值除以 7 时生成商和余数。解释代码 (IMO) 的最简单方法是举例说明,所以让我们考虑将0xa732 除以 7。预期结果是:

quotient = 0x17e2
remainder = 4  

我们首先将输入视为两个 8 位值,upper 字节和 lower 字节。 upper 字节为0xa7lower 字节为0x32

我们从 upper 字节计算商和余数:

0xa700 / 7 = 0x17db
0xa700 % 7 = 3 

所以我们需要三个表:

  • upperHigh存储商的高字节:upperHigh[0xa7] = 0x17
  • upperLow 存储商的低字节:upperLow[0xa7] = 0xdb
  • upperRem 存储剩余部分:upperRem[0xa7] = 3

我们计算lower字节的商和余数:

0x32 / 7 = 0x07
0x32 % 7 = 1

所以我们需要两张表:

  • lowerLow 存储商的低字节:lowerLow[0x32] = 0x07
  • lowerRem 存储余数:lowerRem[0x32] = 1

现在我们需要汇总最终答案。余数是两个余数之和。由于每个余数都在 [0,6] 范围内,因此总和在 [0,12] 范围内。因此,我们可以使用两个 13 字节查找将和转换为最终余数和进位。

商的低字节是该进位与lowerLowupperLow 表中的值之和。请注意,总和可能会在高字节中产生进位。

商的高字节是该进位与upperHigh 表中的值之和。

所以,完成这个例子:

remainder = 1 + 3 = 4              // simple add (no carry in)
lowResult = 0x07 + 0xdb = 0xe2     // add with carry from remainder
highResult = 0x17                  // add with carry from lowResult

实现这一点的汇编代码由 7 个表查找、一个不带进位的加法指令和两个带进位的加法指令组成。


#include <stdio.h>
#include <stdint.h>

uint8_t upperHigh[256];  // index:(upper 8 bits of the number)  value:(high 8 bits of the quotient)
uint8_t upperLow[256];   // index:(upper 8 bits of the number)  value:(low  8 bits of the quotient)
uint8_t upperRem[256];   // index:(upper 8 bits of the number)  value:(remainder when dividing the upper bits by 7)
uint8_t lowerLow[256];   // index:(lower 8 bits of the number)  value:(low  8 bits of the quotient)
uint8_t lowerRem[256];   // index:(lower 8 bits of the number)  value:(remainder when dividing the lower bits by 7)
uint8_t carryRem[13]    = { 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1 };
uint8_t combinedRem[13] = { 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5 };

void populateLookupTables(void)
{
    for (uint16_t i = 0; i < 256; i++)
    {
        uint16_t upper = i << 8;
        upperHigh[i] = (upper / 7) >> 8;
        upperLow[i] = (upper / 7) & 0xff;
        upperRem[i] = upper % 7;

        uint16_t lower = i;
        lowerLow[i] = lower / 7;
        lowerRem[i] = lower % 7;
    }
}

void divideBy7(uint8_t upperValue, uint8_t lowerValue, uint8_t *highResult, uint8_t *lowResult, uint8_t *remainder)
{
    uint8_t temp = upperRem[upperValue] + lowerRem[lowerValue];
    *remainder = combinedRem[temp];
    *lowResult = upperLow[upperValue] + lowerLow[lowerValue] + carryRem[temp];
    uint8_t carry = (upperLow[upperValue] + lowerLow[lowerValue] + carryRem[temp]) >> 8;  // Note this is just the carry flag from the 'lowResult' calcaluation
    *highResult = upperHigh[upperValue] + carry;
}

int main(void)
{
    populateLookupTables();

    uint16_t n = 0;
    while (1)
    {
        uint8_t upper = n >> 8;
        uint8_t lower = n & 0xff;

        uint16_t quotient1  = n / 7;
        uint16_t remainder1 = n % 7;

        uint8_t high, low, rem;
        divideBy7(upper, lower, &high, &low, &rem);
        uint16_t quotient2 = (high << 8) | low;
        uint16_t remainder2 = rem;

        printf("n=%u q1=%u r1=%u q2=%u r2=%u", n, quotient1, remainder1, quotient2, remainder2);
        if (quotient1 != quotient2 || remainder1 != remainder2)
            printf(" **** failed ****");
        printf("\n");

        n++;
        if (n == 0)
            break;
    }
}

【讨论】:

  • 可能是个傻问题,但是upperHighlowerLow的内容有区别吗?
  • @Damien_The_Unbeliever 哎呀,他们确实是一样的。我会更新答案,谢谢!
  • 如果它们相同,我很困惑它如何增加任何清晰度以将它们分开......组合表可以简单地具有反映其两种不同用法的名称,而不是浪费额外的256 字节不提供任何性能优势和微不足道的可读性优势,这无法通过简单地使用描述性命名来等效地获得。
  • @markt1964:在asm中,只需将两个标签放在同一个地方,不要实际上复制内存。在 C 中,#definestatic const uint8_t *upperHigh = lowerLow;
【解决方案2】:

Unsigned Integer Division Routines 为 8 位除以 7:

;Divide by 7 (From December '84 Apple Assembly Line)
;15 bytes, 27 cycles
  sta  temp
  lsr
  lsr
  lsr
  adc  temp
  ror
  lsr
  lsr
  adc  temp
  ror
  lsr
  lsr

估计大约 100 个循环的移位非常准确:104 个循环到最后一个 ror,总共 106 个循环不包括rts,整个函数为 112 个循环。
注意:在为 C64 组装并为 C64 使用 VICE 仿真器后,我发现算法失败,例如 65535 给出 9343,正确答案是 9362。

   ; for 16 bit division  by 7
   ; input:
  ;   register A is low byte
  ;   register X is high byte
  ; output 
  ;   register A is low byte
  ;   register X is high byte
  ;
  ; memory on page zero
  ; temp     is on page zero, 2 bytes
  ; aHigh    is on page zero, 1 byte
  --
  sta temp
  stx temp+1
  stx aHigh
  --
  lsr aHigh
  ror a
  lsr aHigh
  ror a
  lsr aHigh
  ror a
  ---
  adc temp
  tax
  lda aHigh
  adc temp+1
  sta aHigh
  txa
  --
  ror aHigh
  ror a
  lsr aHigh
  ror a
  lsr aHigh
  ror a
  --
  adc temp
  tax
  lda aHigh
  adc temp+1
  sta aHigh
  txa
  --
  ror aHigh
  ror a
  lsr aHigh
  ror a
  lsr aHigh
  ror a     -- 104 cycles
  ;-------
  ldx aHigh  ; -- 106
  rts        ; -- 112 cycles

【讨论】:

  • 您的代码计算 ((x / 8 + x) / 8 + x) / 8,其中 OP 的公式为 (x + x / 8 + x / 64) / 8。除以 7 哪个正确?
  • 我知道您概括了将 byte 除以 7 的算法,但除了 x/8x/64 以及 x/512 , x/4096x/32768 是必需的吗?这将使循环次数达到 250 个!
  • 我之前确实访问过该链接,而您的确实是那里找到的代码的正确翻译。仍然不是OP写的。也许您需要在回答中明确这一点。
  • 九月,我不明白原来的 8 位除法是如何工作的。我只扩展 16 位值。
  • Alvalongo,当您回复他们的评论时,使用@SepRoland 通知他们。 (您会收到通知,因为我们正在根据您的回答发表评论。)
【解决方案3】:

另一种方法是将除法转换为乘法。

为了计算乘数,我们有兴趣取倒数。本质上我们是这样做的:

d = n*(1/7)

为了使事情更准确,我们乘以 2 的方便幂。2^16 效果很好:

d = floor(n*floor(65536/7)/65536)

乘数为:floor(65536/7) 即 9362。 结果的大小将是:

ceiling(log2(65535*9362)) = 30 bits (4 bytes rounded up)

然后我们可以丢弃低两个字节除以 65536,或者只是使用高两个字节作为最终结果。

为了计算出我们需要的实际旋转和相加,我们检查因子 9362 的二进制表示:

10010010010010

注意位模式的重复。所以一个有效的方案是计算:

((n*9*256/4 + n*9)*8 + n)*2 = 9362*n

计算 n*9 只需要上限(log2(65535*9)) = 20 位(3 字节)。

在伪汇编中是:

LDA number          ; lo byte
STA multiply_nine
LDA number+1        ; high byte
STA multiply_nine+1
LDA #0              
STA multiply_nine+2 ; 3 byte result

ASL multiply_nine   ; multiply by 2
ROL multiply_nine+1
ROL mulitply_nine+2
ASL multiply_nine   ; multiply by 2 (4)
ROL multiply_nine+1
ROL mulitply_nine+2
ASL multiply_nine   ; multiply by 2 (8)
ROL multiply_nine+1
ROL mulitply_nine+2

CLC                 ; not really needed as result is only 20 bits, carry always zero
LDA multiply_nine
ADC number
STA multiply_nine
LDA multiply_nine+1
ADC number+1
STA multiply_nine+1
LDA multiply_nine+2
ADC #0
STA multiply_nine+2 ; n*9

我将剩下的练习留给 OP。请注意,不需要乘以 256,因为这只是一个完整的字节上移。

【讨论】:

  • 我认为这类似于编译器在具有快速乘法指令的机器上使用的相同定点乘法逆,用于在所有可能的红利上精确除以常数。但为此,您通常必须右移完整乘法结果的高半部分。 Why does GCC use multiplication by a strange number in implementing integer division?。是的,对于像7 这样棘手的除数,x86 上还有更多步骤:godbolt.org/z/e5ut7S。这个定点版本是否完全适用于所有 16 位红利?
  • 快速检查 Excel 表明它并不准确。但在最坏的情况下只会出局 1,这可能会也可能不会。然而,总是可以以牺牲代码大小为代价来提高准确性。我希望使用 2^24 比例因子可以消除差异。使用循环进行重复移位可以减少代码大小,但这需要更多的循环。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-05-04
  • 2015-09-06
  • 2021-09-11
  • 2023-03-14
相关资源
最近更新 更多