优化第 7 代 Intel Core 视频 RAM 中递增的 ASCII 十进制计数器

Optimizing an incrementing ASCII decimal counter in video RAM on 7th gen Intel Core

我正在尝试针对特定的 Kaby Lake CPU (i5-7300HQ) 优化以下子例程,理想情况下使代码与其原始形式相比至少快 10 倍。该代码在 16 位实模式下作为软盘式引导加载程序运行。它在屏幕上显示一个十位数的十进制计数器,计数 0 - 9999999999 然后停止。

我查看了 Agner 的 Microarchitecture and Assembly, Instruction Performance Table and Intel's Optimization Reference Manual 优化指南。

到目前为止我能做的唯一明智的优化是将 loop 指令替换为 dec + jnz,解释 here.

另一种可能的优化方法可能是将 lodsb 换成 mov + dec,但我发现的相关信息一直存在冲突,有些人说这会略有帮助,而另一些人则说这实际上可能会造成伤害现代 CPUs.

的表现

我还尝试切换到 32 位模式并将整个计数器保留在一个未使用的寄存器对中以消除任何内存访问,但在稍微阅读之后我意识到这十个位将立即被缓存并且区别L1 缓存和寄存器之间的延迟仅为三分之一,因此绝对不值得使用该格式的计数器增加开销。

(编者注:add reg 延迟为 1 个周期,add [mem] 延迟约为 6 个周期,包括 5 个周期的存储转发延迟。如果 [mem] 不可缓存则更糟像视频 RAM。)

org 7c00h

pos equ 2*(2*80-2)  ;address on screen

;init
cli
mov ax,3
int 10h
mov ax,0b800h
mov es,ax
jmp 0:start

start:
    push cs
    pop ds
    std

    mov ah, 4Eh
    xor cx, cx
    mov bl,'9'

countloop:
    mov cl,10           ;number of digits to add to
    mov si,counter+9    ;start of counter
    mov di,pos          ;screen position

    stc                 ;set carry for first adc
next_digit:
    lodsb               ;load digit
    adc al,0
    cmp bl, al
    jnc print
    add al,-10          ;propagate carry if resulting digit > 9
print:
    mov [si+1],al       ;save new digit
    stosw               ;print

    ;replaced loop with a faster equivalent
    ;loop next_digit
    dec cl
    jnz next_digit

    jnc countloop

    jmp $

counter:
    times 10 db '0'

    times 510-($-$$) db 0
    dw 0aa55h

我的问题是 - 我该怎么做才能达到预期的速度提升?我还可以学习哪些其他 material 来更好地理解基本概念?

注意:此学校作业。虽然直接回答肯定会有帮助,但我更希望得到相关研究的解释或指导 material,因为我们已经得到 none.

编辑:将代码更改为最小的可重现示例

这是我的看法。已应用以下优化:

  • 最低有效数字已完全展开以获得最佳性能
  • 剩余数字已展开为每个数字一个部分
  • 已使用 BCD 算法将代码缩减为每个数字一个条件分支
  • 段的使用已经改组以减少使用的前缀数量
  • 指令顺序已优化,将长延迟指令移出关键路径

此外,我已将代码更改为 COM 二进制文件以便于测试。将它变回引导加载程序留给 reader 作为练习。一旦它成为引导加载程序,您可以做的一件事是修复代码,使 CSSS 的段基数为 0000。这避免了对某些微体系结构的加载和存储的惩罚。

        org     100h

pos     equ     2*(2*80-12)             ; address on screen

        mov     ax, 3                   ; set up video mode
        int     10h
        mov     ax, 0b800h
        mov     ds, ax
        mov     es, ax

        mov     di, pos
        mov     ax, 4e30h               ; '0' + attribute byte 4e
        mov     cx, 10
        cld
        rep     stosw                   ; set up initial display

        xor     ax, ax
        sub     sp, 10
        push    ax
        push    ax
        push    ax
        push    ax
        push    ax
        mov     bp, sp                  ; set up counter

        dec     di
        dec     di                      ; di points to the last digit on screen
        mov     bx, digits              ; translation table

        jmp     countloop

%macro  docarry 1                       ; digits other than the last one
        mov     al, [bp+%1]             ; second to last digit
        inc     ax                      ; add carry to al
        aaa                             ; generate BCD carry
        mov     [bp+%1], al             ; desposit to counter
        cs xlat                         ; generate ASCII digit
        mov     [di-2*9+2*%1], al       ; display digit
        jnc     countloop               ; exit when carry dies
%endm

docarry2:                               ; place this here so jumps are in range
        docarry 2
        docarry 1
        docarry 0
        int     20h

        align   16                      ; for performance
countloop:
        mov     [di], byte '0'          ; treat last digit separately
        mov     [di], byte '1'
        mov     [di], byte '2'
        mov     [di], byte '3'
        mov     [di], byte '4'
        mov     [di], byte '5'
        mov     [di], byte '6'
        mov     [di], byte '7'
        mov     [di], byte '8'
        mov     [di], byte '9'

        docarry 8
        docarry 7
        docarry 6
        docarry 5
        docarry 4
        docarry 3
        jmp     docarry2

digits:
        db      '0123456789'

与我基于 8 MHz 80286 的机器上的原始代码相比,这将速度提高了约 30 倍,并且设法使计数器每秒递增约 329000 次(每个数字约 3.04 微秒)。在现代系统上测试会有点困难,但我会尝试找到解决方案。

如果森林里有一个计数器,有人看到了吗?

our requirements state that every single change of a number has to be visible on screen

你的屏幕刷新率大概是60Hz,可能高达144Hz。以比这更快的速度更改视频 RAM 将使一些计数未被帧缓冲区 1 上的硬件扫描循环读取,永远不会发送到物理屏幕,也永远不会变成可见光子的模式high-speed 相机可以记录的光线。

脚注 1:或者如果 VGA 文本模式以某种方式在只知道如何绘制像素的硬件之上模拟,则虚拟等价物。被问到 Does modern PC video hardware support VGA text mode in HW, or does the BIOS emulate it (with System Management Mode)? 作为跟进。

如果我们不接受每 16.66..ms (60 Hz) 1 增量的限制,我们需要决定我们愿意在哪些方面成为瓶颈,哪些我们可以回避。

当然,我们需要完成计算 ASCII 数字的实际工作,而不仅仅是在定时器或 vertical blanking 中断(每次屏幕刷新一次)中偶尔递增二进制计数器并将其格式化为字符串。那不符合作业的精神。

或者如果我们只在寄存器中计算 ASCII 数字并且只 mov 存储在定时器或 vblank 中断中怎么办?这将从 fast-incrementing 计数器的增量中异步对其进行采样,因此您可以直观地看到所有低位数字都在变化。 (这是一个非常明确的最低要求)。

从实际循环中省略存储仍然不符合作业的精神。 我认为我们的循环应该,如果 运行 在没有花哨的硬件设置的情况下,真正获得所有计数到视频 RAM。 这似乎没有争议。这就是原始代码的作用。

CPU 可以配置为 write-combining 和 MTRRs. Some desktops had a BIOS option to set the AGP GART as UC (UnCacheable) vs. WC (calling it "USWC = Uncacheable Speculative Write Combining"). This BIOS-tuning article has a section on it。似乎现代固件离开了 VGA 内存 UC,让操作系统/图形驱动程序设置 MTRRs/PAT。

不幸的是,使 VGA 内存 WC 工作 并且存储永远不会超出 CPU 核心的 write-combining 缓冲区。 (一个 LFB,因为这是一个 Intel CPU。)我们可以在每次存储后手动刷新内存屏障,如 mfenceclflushopt 以及缓存行的地址。但随后我们又回到了起点,因为在 OP 的 Kaby Lake iGPU / 固件上,似乎冲洗 WC 商店的成本与仅冲洗 UC 商店的成本大致相同。

当然,我们只需要在整个计数器同步时刷新,如果进位波纹很远,则在更新所有数字之后。如果我们分别存储每个数字,如果我的数学正确与 UC 内存相比,这可以使我们的速度提高 11.111%。或者,如果我们一次存储 2 位数的双字存储,则增加 1.0101%,因为我们只需要每 100 个计数而不是每 10 个额外存储一次。

我认为我们可以通过在计时器或 vblank 中断中使用 WC 帧缓冲区和 刷新 来捕捉任务的精神,同时仍然让硬件优化我们的存储。

这意味着我们正在非常快速地递增计数器(仔细实施后每个核心时钟周期将近 1 个计数)。我们 采样 仅通过在中断处理程序中使用内存屏障或序列化指令来 运行 在视频硬件开始新的传递之前 运行屏幕,扫描出一个新的框架。事实上 iret 正在序列化,所以仅仅从一个空的中断处理程序返回就可以完成这项工作。如果您使用 MTRR 制作视频 RAM WC 但没有编程定时器或 vertical-blanking 中断触发,则按住键盘上的一个键甚至可以使计数器更新在屏幕上可见(否则它们不会显示)定期。

在循环的外层使用clflushmfence效果不佳;这将与增量同步,因此会使低位数字始终为零。这将使我们有时只在循环中显式刷新,而不是将刷新留作由于中断而发生的事情,而中断是正常系统操作的一部分。 (或者至少如果这个引导加载程序不是字面上唯一的 运行ning 的话,他们会是。例如,如果 运行 在 DOS 下,你每隔几毫秒就会有一个定时器中断。)


如果我们坚持每次计数都刷新到视频 RAM(通过保留 UC,或手动使用 WC + 循环中的显式刷新),唯一重要的优化是减少存储到视频 RAM。 即不更新未更改的数字。原始代码每次都会存储每个数字,因此修复该代码应该可以提供非常接近 10 倍的加速。

即使只是存储到不可缓存的 DRAM 或进行 PCIe 事务也比您可以在循环内优化的任何事情慢得多,甚至是 self-modifying-code 机器清除。如果存储到 VGA 文本帧缓冲区会触发系统管理模式中断 (SMI) 以通过更新真实像素帧缓冲区来模拟文本模式,那么与任何其他帧相比,存储到帧的成本是天文数字否则你可以在循环中做。这很可能就是 Skylake / Kaby Lake 集成 GPU 固件的工作方式:Does modern PC video hardware support VGA text mode in HW, or does the BIOS emulate it (with System Management Mode)?

允许硬件在我们的商店中对 VRAM 执行 write-combining 因此,除了一个算法调整之外,对于使这个优化问题变得有趣是必不可少的。

为此,为 VGA 帧缓冲区编程 MTRRhttps://wiki.osdev.org/MTRR documents the actual MSRs you can use with the wrmsr instruction。我认为每个 MSR 都有 8 个区域的 bit-field。您想要的是 IA32_MTRR_FIX16K_A0000,在 MSR[259] - 8 个区域,每个区域 16 KB(总共 128 KB),其中包括线性地址块 B8000拥有 VGA text-mode 内存。 Intel 的 SDM 第 3 卷中的图 11-8 记录了布局。


假定WC显存(或用于更新WB可缓存内存)

有很多地方需要改进,但有两点很关键:

  • Micro-architectural:Self-modifying code pipeline nukes,又名机器清除,来自 count[] 与相同的 64B 缓存行你的主循环(~50 倍性能,没有其他变化。)如果不改变这个,很难从任何其他 micro-optimizations.

    中看到任何收益
  • 算法:不要每次都盲目地将进位一直传播到每个数字:90%的增量根本不进位, 99% 只携带 1 个位置,等等。处理低位的嵌套循环可以 运行 非常有效,只需递增它们自己的数字计数器并让外循环将其重置为 '0',无需显式传播那些携带 adc。将这些 ASCII 数字保存在寄存器中也避免了将它们 load/store 到 counts[] 的需要,只是纯粹存储到视频 RAM,如 mov [di-4], eax.

    对于低位数字非常有效的内部循环,高 6 或 7 位数字的性能几乎变得无关紧要。该部分 运行s 每 10k 或 1k 增量一次,因此其成本被摊销。 (~19 倍加速 用于积极优化的内部循环与原始循环的 micro-optimized 版本相比,它节省了一些微指令并避免了一些瓶颈而不改变算法。)

你原来的其他micro-optimizations(修复SMC机器清除后)给出了~1.5倍的加速因子:正常进行进位分支not-taken,节省一些微指令,避免一些partial-register 来自 lodsb 的错误依赖并写入 16 位部分寄存器。

借助我从头开始重写的优化的 4 级内部循环,我的版本在 Skylake / Kaby Lake 上比 no-SMC-stall 原始版本 快约 29 倍,或比真正的原始速度快 1500 倍。当然有一个中间立场,你可以 adc 进行传播,但在 CF==0 时尽早退出;我没有尝试实现它。

已在 32 位模式下测试,但 16 位模式下的相同代码 assembled 应该以相同的方式执行,包括原始文件中的 SMC 停顿。 (假设 WC 存储在刷新之前不会触发 SMI,并且 WC 缓冲区将存储保持在核心内部本地,因此就像 WB 内存一样,~1 个存储/时钟是可能的。)

SKL 和 KBL clock-for-clock 在性能和微架构方面完全相同,所以我的测试结果应该可以为您重现。我在 16 位模式下执行 assemble 你的代码以查看对齐方式:看起来你的循环将在与循环结束相同的 64 字节缓存行中有一些 count[] 字节,因此大多数数字每次迭代的 SMC 管道核弹。


我修改了您的原始代码,因此我可以 运行 在 Linux 下的 32 位模式下使用相同的循环,从而可以使用 perf 进行分析与硬件性能计数器。 优化任何东西的第一步是获得基准测量。由于您出于 micro-architectural 的原因提到了一些 micro-optimizations,我们希望性能计数器不仅仅是总时间。我们无法在裸机上的引导加载程序中轻松获得它。可能在访客 VM 中,但随后您将存储到虚拟 VGA 设备,而不是真实硬件,因此它可能与在 Linux 下 user-space 中的普通 WB 内存上使用普通或 NT 存储没有什么不同.

perf stat -I1000 显示每秒完成的工作量的计数器是比较不改变算法或分支数量的调整速度的便捷方法。查看 1 秒内分支的计数以查看循环的相对速度,或将其除以周期数。

我使用 movnti 尝试模拟存储到 WC 视频 RAM(不可缓存的推测 Write-Combining,而不是正常的 WB = write-back 可缓​​存)。我认为 WC 内存区域的正常存储行为类似于 movnt 存储。 movnt 未完成缓存行的存储可以继续更新相同的 write-combining LFB 而无需实际刷新到内存。所以它类似于可以在 L1d 缓存中命中的 WB 内存的正常存储。

帧缓冲区存储的 SMI 捕获(如果有的话)是由 CPU 核心之外的硬件完成的,可能是系统代理,因此它不会触发直到核心刷新。或者如果没有 SMI 陷阱,那么它可能只是转到我们 iGPU 系统上的 DRAM。或者通过 PCIe 总线到达视频 RA在单独的卡上。


在 i7-6700k 上的 GNU/Linux 内核 5.5.10 下的版本,在 ~4.2GHz 的有点空闲的系统上

几乎不涉及 DRAM 和高速缓存,并且系统空闲到足以在物理核心的另一个逻辑核心上没有任何循环,所以代码在整个时间里都有一个完整的 CPU垃圾邮件存储到 write-combining 缓冲区。

  • 原始版本,在 32 位 user-space 中移植到 运行:Godbolt - 未完全定时,但 perf stat -I1000 每秒打印统计数据表明它 运行ning 比 counter: 之前的 align 64 慢了大约 52 倍。 pipeline nuke 可能包括刷新 WC 缓冲区,这也意味着进入 DRAM。
  • 原始版本,避免了 SMC 流水线核爆:约 85.7 秒,约 3580 亿个核心时钟周期,用于 10^10 次计数。 2.66 IPC
  • Micro-optimized 版本:Godbolt - ~55.3 秒,~2310 亿个时钟周期,用于 10^10 个计数。 4.56 IPC(但有更简单的指令,不是 lodsb)
  • 新内循环,空占位符外循环:Godbolt - ~2.93 秒,~122.5 亿核心时钟周期。 2.73 IPC

优化版本每 4 个时钟实现近 3 个存储。 (从 00..99 开始计算低 2 位数字需要 100 次存储,它是这样做的。我没有用 clflushopt 计算这些最终版本的时间。)


如果您修复了一些停顿并使用 CF==0 停止了循环,这将导致 store/reload(store-forwaring)延迟到 [=37 的低元素的瓶颈=]数组。你肯定想要寄存器中的那些,所以它们可以是 store-only,而不是 load/adc/store。

TODO:评论说说我申请那个版本的micro-optimizations:

  • / - also lodsb sucks. lodsd/ q are ok. Use movzx to do narrow loads, instead of merging into the low byte. Fortunately inc/dec in an adc loop on Sandybridge-family is fine, not causing partial-flag stalls like . Especially in Skylake which doesn't do flag-merging at all, instead just reading the CF and/or SPAZO parts of FLAGS separately if needed. (Consequence: cmovbe and cmova 是 2 微指令读取 2 个整数输入和 CF + ZF;其他 cmov 只有 1 uop。)

  • 可以在16位模式下使用32位寄存器,不用切换模式。 assembler 仅使用 operand-size 前缀。写入 32 位寄存器不依赖于旧值,但 16 位或 8 位寄存器依赖于旧值。 我用它来打破依赖链,否则会是 loop-carried、allowing the CPU to exploit the instruction-level parallelism (ILP) across loop iterations / http://www.lighterra.com/papers/modernmicroprocessors/.

  • Haswell/Skylake已经取了1/clock的分支吞吐量,但是可以运行一个not-taken和一个取在同一个周期。在快速路径上布置有利于 not-taken 的分支(总的来说总是一个好主意)。

  • adc eax,0adc bl,0 不同,
  • - 不幸的是,adc al,0 在 Skylake 上是 2 微指令。疯了吧?这基本上是硬件设计者的 CPU 性能错误或 CPU missed-optimization,其中较小编码的 special-case 操作码解码更差。

  • 32-byte aligned routine does not fit the uops cache - Intel 最近的 JCC 勘误使 idq.mite_uops perf 事件值得检查。 Skylake 过去对代码对齐非常稳健,但现在它对 high-throughput 代码很糟糕。

    Perf 并没有完全跌落悬崖,但一个重要的因素是可能的,因为 front-end 瓶颈必须对一些以 [= 结尾的 32 字节机器代码块使用旧版解码51=] 在 32 字节边界上。我没有花很多精力对这段代码进行优化,但是根据性能计数器,快速版本恰好避免了这个问题。

我的版本带有嵌套循环,可在 GNU/Linux

上测试

这只是内循环;外循环只是重复它 10^10 / 10k 次而没有实际的 outer-loop 工作。我们每 10k 增量只保留内部 4 个循环一次,因此假装该部分花费零时间不会特别改变结果。

每个寄存器 2 层嵌套循环的相同模式可以重复更多次,或者像您所做的那样做一系列 adc

;; nasm -felf32 decimal-counter.asm
;; ld -N -melf_i386 -o decimal-counter decimal-counter.o
;; writeable text segment like a bootloader
;; runs in 32-bit mode with prefixes for 16-bit operand-size
;;
;; taskset -c 3 perf stat -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,resource_stalls.any:u,rs_events.empty_cycles:u,machine_clears.count:u -I1000 ./decimal-counter

%use smartalign
alignmode p6, 64

;org 7c00h

;pos equ vram + 2*(2*80-2)  ;address on screen
pos equ vram + 2*(2*80-4)  ;address on screen

    ; In GDB, use
    ; p ((char*)&vram) + 2*(2*80-4)-36

;init
;cli
;mov ax,3
;int 10h
;mov ax,0b800h
;mov es,ax
;jmp 0:start


 ; pick your poison, or let stores stay in the CPU, not reaching VRAM
%macro FLUSH 1
 ;  clflushopt %1           ; all the way to DRAM
 ;  mfence                  ; for mov to WB: just drain store buffer.  For WC or movnt, IDK how guaranteed it is to hit DRAM
;   lock xor byte [esp], 0   ; faster version of mfence (at least on Skylake)
%endmacro
;%define movnti mov         ; for experiments

global _start
align 512
_start:
;    push cs
;    pop ds
;    mov ebp, counter+9    ; save address in a register
;    mov edi,pos
    mov edi, pos - 10*4
    mov eax, '0_0_'
    mov ecx, 10
    rep stosw                   ; memset the digits in VRAM

    mov  ebp, 10000000000 / 10000     ; outer loop iterations
    mov edi, pos-4

;    mov ah, 4Eh         ; VGA attribute byte
;    mov eax, '____'

align 32
.outer:

    mov  edx, '0_0_'           ; thousands (low), hundreds (high) digits
.thousands:
 .hundreds:
    movnti  [edi-4], edx
    ; don't want to flush yet; only after low digits are updated
    add  edx, 1<<16

    mov  eax, '0_0_'            ; tens (low=AX), ones (high) digits
    .tens:
        .ones:                  ; do{
          movnti  [edi], eax         ; store low 2 digits
        FLUSH [edi]
          lea  ecx, [eax + (1<<16)]       ; off the critical path of the EAX dep chain
          movnti  [edi], ecx
        FLUSH [edi]
          add  eax, 2<<16               ; unroll by 2
          cmp  eax, '9_'<<16
          jle  .ones            ; }while(ones<='9')
                   ; mov byte [edi+2], '9'    ; peel the last 2 iterations?

        add  eax, ('1_0_') - ('0_0_' + (10<<16))     ; increment the more-significant digit (AL), resetting less-significant digit back to '0'
        cmp  al, '9'
        jle  .tens

    cmp  edx, '9_9_'
    jle  .hundreds

    add  edx, ('1_0_') - ('0_0_' + (10<<16))     ; increment the more-significant digit (DL), resetting less-significant digit back to '0'
    cmp  dl, '9'
    jle  .thousands

;; TODO: increment the high 6 digits, propagating carry.  Possibly clflushopt here only?
;    pause
    dec ebp
    jnz .outer
    ;    jmp $
    mov eax, 1
    int 0x80


;section .data   ; avoids machine clears
    ; in original 16-bit code: counter starts at 00000037 30<rept>, ends at 00000040 (inclusive), in same cache line as the loop
align 64
counter:
    times 10 db '0'
;section .text

    times 510-($-$$) db 0
    dw 0aa55h

section .bss
vram:   resw 80*25

我已经测试过它适用于低位,single-stepping它在GDB中并使用display ((char*)&vram) + 2*(2*80-4)-36或类似的东西来显示内容BSS 的那部分作为每一步的字符串。

使用双字存储意味着当个位换行时我们不需要单独的存储来更新十位。它只需要更新同一个寄存器的低字节,并让内循环的第一次迭代进行存储。

在从 0099 翻转到 0100 期间,内存内容暂时为 0199。但是除非您使用 SSE 一次存储 16 个字节,否则您无法真正避免一个问题或另一个问题。另一种选择是以某种方式在 0100 之前安排 0000,但这可能会浪费存储到数百个循环中的 tens/ones 双字。

当您写入帧缓冲区时,最好将其视为在网络上发送数据包。 "write packet" 有一个 header 包含地址、大小、数据(加上可能 checksum/parity)。如果写入一个字节,数据包的数据部分将与数据包的大小header相形见绌,因此大部分带宽将被浪费。为了有效利用可用带宽,您需要更少的大写操作。写入合并可以提供帮助(为您将多个小写入合并为一个大写入),但在您自己优化写入后,它应该被视为潜在的小改进,而不是未能优化写入的借口。

假设 "generic 32-bit 80x86 CPU"(例如 80486,没有 SSE 或 AVX);您的主要目标应该是将数据安排为五个 32 位写入;其中每个 32 位写入包含两个 "char + attribute" 对。换句话说,写入应该看起来有点像:

    mov di,pos
    mov [di],eax
    mov [di+4],ebx
    mov [di+8],ecx
    mov [di+12],edx
    mov [di+16],esi

注意:在实模式或16位代码中使用32位指令都没有问题(只要CPU为80386或更高版本)。

但是;这是一个柜台。这意味着 99% 的时间你只需要进行一次写入(这也会使写入合并 99% 变得毫无价值)。更具体地说,如果最低 2 位翻转(从“99”到“00”),则只需要第二次写入,如果最低 4 位翻转(从“9999”到“0000”),则只需要第三次写入), 等等

那么..让我们初始化一个计数器:

    mov di,pos
    mov eax,0x4E304E30
    mov ebx,0x4E304E30
    mov ecx,0x4E304E30
    mov edx,0x4E304E30
    mov esi,0x4E304E30
    mov [di],esi
    mov [di+4],edx
    mov [di+8],ecx
    mov [di+12],ebx
    mov [di+16],eax

然后你想增加它并更新屏幕:

.update:
    add eax,0x00010000
    cmp eax,0x4E390000
    ja .digit1rollover
    jmp .done1

.digit1rollover:
    add eax,0x00000001-0x000A0000
    cmp al,0x39
    ja .digit2rollover
    jmp .done1

.digit2rollover:
    mov eax,0x4E304E30
    add ebx,0x00010000
    cmp ebx,0x4E390000
    ja .digit3rollover
    jmp .done2

.digit3rollover:
    add ebx,0x00000001-0x000A0000
    cmp bl,0x39
    ja .digit4rollover
    jmp .done2

.digit4rollover:
    mov ebx,0x4E304E30
    add ecx,0x00010000
    cmp ecx,0x4E390000
    ja .digit5rollover
    jmp .done3

.digit5rollover:
    add ecx,0x00000001-0x000A0000
    cmp cl,0x39
    ja .digit6rollover
    jmp .done3

.digit6rollover:
    mov ecx,0x4E304E30
    add edx,0x00010000
    cmp edx,0x4E390000
    ja .digit7rollover
    jmp .done4

.digit7rollover:
    add edx,0x00000001-0x000A0000
    cmp dl,0x39
    ja .digit8rollover
    jmp .done4

.digit8rollover:
    mov edx,0x4E304E30
    add esi,0x00010000
    cmp esi,0x4E390000
    ja .digit9rollover
    jmp .done5

.digit9rollover:
    add esi,0x00000001-0x000A0000
    cmp si,0x4E39
    ja .digit10rollover
    jmp .done5

.digit10rollover:
    mov esi,0x4E304E30
;   jmp .done5

.done5:
    mov [di],esi
.done4:
    mov [di+4],edx
.done3:
    mov [di+8],ecx
.done2:
    mov [di+12],ebx
.done1:
    mov [di+16],eax

你还想绕过这个。幸运的是 bp/ebp 仍然未被使用,所以这没问题(只是不要忘记在初始化时将 bp 设置为某些东西):

.done:
    dec bp
    jne .update

感谢这里的反馈和讨论(特别感谢 Peter 和他的奉献),我能够确定速度下降的主要原因 - 写入 VRAM,因为该内存不可缓存。

因此,只有两个有意义的优化是一旦我们在添加时丢失进位就跳出循环(这样我们就不会不必要地向每个数字添加零并花时间将其打印到屏幕上)并组合与将 WORD 大小的写入写入 DWORD 大小的一样多。这两个组合能够使我跨越 10 倍加速标记。

我的解决方案(加速 x10.3):

org 7c00h
bits 16             ;enables prefixes for 32bit instructions
pos equ 2*(2*80-2)  ;address on screen

;init textmode and vram, fix CS
cli
mov ax, 3
int 10h
mov ax, 0B800h
mov es, ax
jmp 0:start

start:
    ;fix segments and stack
    mov bp, 7C00h
    xor ax, ax
    mov ds, ax
    mov ss, ax
    mov sp, bp

    ;print initial zeroes
    std
    mov ax, (4Eh << 8) + '0'
    mov cx, 10
    mov di, pos
    sub di, 2
    rep stosw

    ;set color into upper byte of DX
    mov dh, 4Eh

counter_loop:
    cmp cx, 5           ;check whether we are incrementing the first two digits
    je two_digit_loop   ;if so, assume values are set correctly

    ;reset values back to start
    mov bx, counter     ;set counter pointer to first two digits
    mov ax, [bx]        ;load first two digits
    mov di, pos         ;set destination index to the position of the rightmost digit on the screen
    mov cx, 5           ;set number of digit pairs to 5

two_digit_loop:
    ;increment and adjust
    inc ax
    aaa
    jc carry

    ;no carry, update digits and return
    mov dl, al
    or dl, 30h              ;digit to ascii
    mov [es:di - 2], dx     ;write character to screen
    mov [bx], al            ;save value to memory
    jmp counter_loop

carry:
    mov edx, 4E304E30h      ;load '00' in colour
    mov [bx], ax            ;save value to memory
    cmp ax, 0A00h           ;test second digit overflow
    jge continue

    ;no carry on second digit, write and return
    or dl, ah               ;digit to ASCII if not 0x0A
    mov [es:di - 4], edx    ;write both characters at once
    jmp counter_loop

continue:
    ;propagate carry to next digit pair
    mov [es:di - 4], edx    ;write zero as both characters (double-sized write)
    mov [bx + 1], ch        ;save zero as upper value to memory

    ;continue to next digit pair
    add bx, 2           ;move memory to next digit pair
    mov ax, [bx]        ;load next digit pair
    sub di, 4           ;move display pointer by two char+colour pairs
    dec cx              ;and decrement counter
    jne two_digit_loop

    ;we ran out of digits to increment, display arrow and halt
    mov ax, 4E18h
    stosw
    jmp $

;counter, positioned at least 64B away from the code to prevent nuking the instruction pipeline
align 128
counter:
    times 10 db 0

times 510 - ($-$$) db 0
dw 0aa55h