19、ARM汇编代码优化与阅读指南

ARM汇编代码优化与阅读指南

1. 限制问题域进行代码优化

在代码优化中,限制问题域往往能带来显著的效果。当我们仅处理字母字符时,就能完全省去范围比较操作。在ASCII字符集中,大小写字母的唯一区别在于小写字母的第0x20位被置位,而大写字母则没有。这意味着我们可以通过执行位清除(BIC)操作,将小写字母转换为大写字母。不过,如果对特殊字符执行此操作,可能会破坏其中一些字符的位。

在计算机编程中,很多时候我们希望代码对大小写不敏感,即可以输入任意大小写组合。汇编器就是如此,它不关心我们输入的是 MOV 还是 mov 。同样,许多计算机语言也对大小写不敏感,变量名的大小写组合不影响其含义。机器学习算法在处理文本时,通常会将文本转换为标准形式,比如去除所有标点符号并统一大小写。这种标准化处理能节省大量后续处理的额外工作。

下面是一个实现字符串转换为全大写的汇编代码示例,代码位于 upper3.s 中:

//
// Assembler program to convert a string to
// all upper case. Assumes only alphabetic
// characters. Uses bit clear blindly without
// checking if character is alphabetic or not.
//
// X0 - address of input string
// X1 - address of output string
// X2 - original output string for length calc.
// W3 - current character being processed
//
.global _start       // Provide program starting address
.MACRO toupper inputstr, outputstr
      LDR    X0, =\inputstr     // start of input string
      LDR    X1, =\outputstr    // address of output string
      MOV    X2, X1
// The loop is until byte pointed to by R1 is non-zero
loop:  LDRB  W3, [X0], #1         //  load char and increment 
pointer
       BIC   W3, W3, #0x20       //  kill bit that makes it 
lower case
       STRB  W3, [X1], #1         //  store character to output 
str
       CMP   W3, #0               //  stop on hitting a null 
character
       B.NE  loop                 //  loop if character isn't 
null
       SUB   X0, X1, X2          //  get the len by sub'ing the 
pointers
.ENDM
_start:
       toupper      instr, outstr
// Setup the parameters to print our hex number
// and then call Linux to do it.
      MOV    X2,X0         //  return code is the length of 
the string
      MOV    X0, #1        // 1 = StdOut
      LDR    X1, =outstr   // string to print
      MOV    X8, #64       // linux write system call
      SVC    0             // Call linux to output the string
// Setup the parameters to exit the program
// and then call Linux to do it.
      MOV     X0,  #0      // Use 0 return code
      MOV     X8,  #93     //  Service command code 96 
terminates
      SVC     0           //  Call linux to terminate the 
program
.data
instr:  .asciz  "ThisIsRatherALargeVariableNameAaZz//[`{\n"
       .align 4
outstr:       .fill   255, 1, 0

此文件包含 _start 入口点和Linux打印调用,因此无需 main.s 文件。以下是构建和运行此版本代码的输出:

smist08@kali:~/asm64/Chapter 14$ make
as   upper3.s -o upper3.o
ld -o upper3 upper3.o
smist08@kali:~/asm64/Chapter 14$ ./upper3
THISISRATHERALARGEVARIABLENAMEAAZZ[@[
smist08@kali:~/asm64/Chapter 14$

从输出可以看到,字符串末尾有一些特殊字符,部分转换正确,部分则不正确。

除了使用BIC指令消除所有条件处理外,我们还将 toupper 例程实现为宏,以消除函数调用的开销。同时,我们调整了寄存器的使用方式,仅在宏中使用前四个寄存器,这样在调用时就无需保存任何寄存器。

这种优化方式是许多优化手段的典型代表,它表明通过缩小问题域,比如仅处理字母字符而非所有ASCII字符,我们能够节省大量指令。

2. 使用SIMD并行处理

我们可以利用NEON指令一次处理16个字符(16个字符刚好能放入一个128位的V寄存器)。以下是 upper4.s 中的代码示例:

//
// Assembler program to convert a string to
// all upper case.
//
// X0 - address of input string
// X1 - address of output string
// X2 - use as indirection to load data
// Q0 - 8 characters to be processed
// V1 - contains all a's for comparison
// V2 - result of comparison with 'a's
// Q3 - all 25's for comp
// Q8 - spaces for bic operation
.global toupper          // Allow other files to call this routine
       .EQU   N, 4
toupper:
       LDR X2, =aaas
       LDR    Q1, [X2]   // Load Q1 with all as
       LDR X2, =endch
       LDR    Q3, [X2]   // Load Q3 with all 25's
       LDR X2, =spaces
       LDR    Q8, [X2]   // Load Q8 with all spaces
       MOV    W3, #N
// The loop is until byte pointed to by R1 is non-zero
loop:  LDR   Q0, [X0], #16 // load 16 chars and incr pointer
       SUB   V2.16B, V0.16B, V1.16B    // Subtract 'a's
       CMHI  V2.16B, V2.16B, V3.16B     //  compare chars to 
25's
       NOT   V2.16B, V2.16B             //  no CMLO so need to 
not
       AND   V2.16B, V2.16B, V8.16B    //  and result with 
spaces
       BIC   V0.16B, V0.16B, V2.16B    // kill lower-casebit
       STR   Q0, [X1], #16             //  store character to 
output str
       SUBS  W3, W3, #1                 //  dec loop counter and 
set flags
       B.NE  loop                       //  loop if character 
isn't null
       MOV   X0, #(N*16)                //  get the len by 
sub'ing the pointers
       RET                              // Return to caller
.data
aaas:        .fill  16, 1, 'a'          // 16 a's
endch:       .fill  16, 1, 25           //  after shift, chars 
are 0-25
spaces:      .fill  16, 1, 0x20         // spaces for bic

这个例程使用128位寄存器一次处理16个字符。虽然指令数量比之前的一些例程多,但并行处理带来的效率提升是值得的。我们首先将常量加载到寄存器中,因为NEON指令不能使用立即常量,所以这些常量必须存储在寄存器中,并且需要重复16次,以对应16个通道。

然后,我们使用 LDR 指令将16个字符加载到 Q0 中,并采用后索引寻址方式,使指针指向下一个字符块,以便循环处理。

CMHI 是我们首次接触到的NEON比较指令,它能同时比较所有16个通道。如果比较结果为真,目标通道将被置为全1(十六进制为0xFF),否则为0。实际上我们更需要 CMLO 指令,但该指令并不存在,所以我们先执行 CMHI ,再执行 NOT 操作。通过这种方式,我们可以将结果与一个全为0x20的寄存器进行 AND 操作。没有小写字母的通道结果将为0,这意味着在这些通道中, BIC 操作不会清除任何位;而仍有0x20的通道将清除该位,完成大小写转换。

为了使这个例程正常工作,我们需要对 main.s 进行修改,在两个字符串之间添加 .align 4 。这是因为我们只能从字对齐的内存位置加载或存储NEON数据,否则程序运行时会出现“Bus Error”。修改后的代码如下:

instr:  .asciz  "This is our Test String that we will convert. 
AaZz@[`{\n"
      .align 4
outstr:     .fill   255, 1, 0

此代码运行正常,但部分原因是 .data 部分的设置方式。需要注意的是,代码中没有对字符串的空终止符进行测试,该例程仅能处理固定长度的字符串,我们通过循环四次将固定长度设置为4 * 16。NEON处理器很难检测空终止符,如果在NEON处理器外部循环遍历字符来查找空终止符,所做的工作几乎与之前的 toupper 例程相同。以下是在NEON协处理器中进行字符串处理的一些注意事项:
- 不要使用以空字符结尾的字符串,可使用长度字段加字符串的形式,或者使用固定长度的字符串,例如每个字符串都是256个字符,超出最后一个字符的部分用空格填充。
- 对所有字符串进行填充,使其数据存储长度为16的倍数,这样就无需担心NEON处理会超出缓冲区末尾。
- 确保所有字符串都是字对齐的。

3. 代码优化技巧

3.1 避免分支指令

ARM CPU可以同时处理多条指令,如果指令不涉及分支,一切都会很顺利。但当CPU遇到分支指令时,它必须采取以下三种操作之一:
1. 丢弃分支指令之后已完成的所有工作。
2. 对分支可能的走向进行合理猜测,并按此方向继续执行;只有在猜测错误时,才需要丢弃已完成的工作。
3. 同时开始处理分支的两个方向;虽然可能无法完成太多工作,但在确定条件分支的方向之前,仍能完成一些任务。

在Spectre和Meltdown安全漏洞出现之前,CPU在预测分支和保持流水线忙碌方面表现出色。但这些漏洞利用了CPU的这一特性,导致包括ARM在内的CPU厂商减少了相关功能。因此,条件分支指令仍然可能带来较高的开销,并且会导致代码难以维护,形成所谓的“ spaghetti code”。所以,减少条件分支有助于提高性能,使代码更易于维护。

3.2 避免使用昂贵的指令

乘法和除法等指令需要多个时钟周期才能执行完毕。如果能在现有循环中通过加法或减法完成相同的操作,那将有所帮助。此外,还可以考虑使用位操作指令,如左移来实现乘以2的操作。但如果这些指令是算法必需的,那就没有太多优化空间了。

一个技巧是将乘法或除法操作放在FPU或NEON协处理器中执行,这样可以让其他常规ARM指令并行执行。

3.3 大胆使用宏

如果函数调用需要保存大量寄存器到栈中,并在返回前恢复,那么函数调用的开销会很大。此时,我们可以大胆使用宏来消除函数调用和返回指令,以及所有寄存器的保存和恢复操作。

3.4 循环展开

循环展开是指将循环代码重复执行指定次数,从而节省循环控制指令的开销。例如,在NEON版本的3x3矩阵乘法中,我们可以三次调用宏,而不是编写循环。

3.5 保持数据量小

尽管ARM处理器处理64位X寄存器和32位W寄存器的指令所需时间大致相同,但移动大量数据会给内存总线带来压力。要知道,内存总线不仅要移动数据,还要加载要执行的指令,并且要为所有处理核心完成这些操作。因此,减少数据在内存中的移动量有助于提高处理速度。

3.6 谨防过热

单个ARM处理器通常有四个或更多处理核心,每个核心都配备了FPU和NEON协处理器。如果充分利用这些资源,理论上可以并行处理大量数据。但问题是,参与处理的电路越多,产生的热量就越多。

如果处理强度过大,像树莓派这样的单板计算机可能会过热,智能手机在需要持续进行大量处理时也会出现过热现象。通常,在处理器过热之前,会有一些使用指南来限制其负载。不过,处理器不会因过热而损坏,它会检测到过热并自动降速,这会使我们之前的优化工作付诸东流。

4. 浏览Linux和GCC代码

Linux和GNU编译器集合(GCC)都是开源的,这意味着我们可以浏览其源代码,查看其中的汇编部分。这些代码可以在以下GitHub仓库中找到:
- Linux内核:https://github.com/torvalds/linux
- GCC源代码:https://github.com/gcc-mirror/gcc

点击“Clone or download”按钮,选择“Download ZIP”是获取这些代码的最简单方式。在这些源代码中,有几个不错的文件夹可以查看ARM 64位汇编语言源代码:
- Linux内核
- arch/arm64/lib
- arch/arm64/kernel
- arch/arm64/crypto
- GCC
- libgcc/config/aarch64

需要注意的是, arch/arm64/crypto 文件夹中有一些在NEON协处理器加密扩展上实现的加密例程,并非所有处理器都支持这些扩展。这些汇编源代码位于以 .S 结尾的文件中(注意是大写的S),这样它们可以包含C头文件并使用C预处理器指令。

通过研究这些代码,我们可以学到很多知识。例如,下面我们将看看Linux内核是如何复制内存页面的。

5. 复制内存页面

Linux内核包含特定于机器的代码,用于处理CPU初始化、中断处理和多任务处理等任务。它还包含许多C运行时函数和其他特殊函数的汇编语言版本,这些版本优化了Linux内核的性能。

Linux内核不使用C运行时库,因为C运行时库必须在Linux启动后进行初始化,而Linux内核有自己的一些关键运行时函数副本。此外, arch/arm64/lib 文件夹中包含了针对特定机器的高度优化版本,我们可以从这些函数中学到很多东西。

Linux内核的虚拟内存管理器以4K页面为单位为进程分配内存,因此高效地操作这些页面对于Linux内核的性能至关重要。下面是Linux内核中实现页面复制的代码示例,来自正在开发的Linux 5.6内核的 arch/arm64/lib/copy_page.S 文件:

/* SPDX-License-Identifier: GPL-2.0-only */
/*
 * Copyright (C) 2012 ARM Ltd.
 */
#include <linux/linkage.h>
#include <linux/const.h>
#include <asm/assembler.h>
#include <asm/page.h>
#include <asm/cpufeature.h>
#include <asm/alternative.h>
/*
 * Copy a page from src to dest (both are page aligned)
 *
 * Parameters:
 *     x0 - dest
 *     x1 - src
 */
SYM_FUNC_START(copy_page)
alternative_if ARM64_HAS_NO_HW_PREFETCH
       // Prefetch three cache lines ahead.
       prfm   pldl1strm, [x1, #128]
       prfm   pldl1strm, [x1, #256]
       prfm   pldl1strm, [x1, #384]
alternative_else_nop_endif
       ldp    x2, x3, [x1]
       ldp    x4, x5, [x1, #16]
       ldp    x6, x7, [x1, #32]
       ldp    x8, x9, [x1, #48]
       ldp    x10, x11, [x1, #64]
       ldp    x12, x13, [x1, #80]
       ldp    x14, x15, [x1, #96]
       ldp    x16, x17, [x1, #112]
       add    x0, x0, #256
       add    x1, x1, #128
1:
       tst    x0, #(PAGE_SIZE - 1)
alternative_if ARM64_HAS_NO_HW_PREFETCH
       prfm   pldl1strm, [x1, #384]
alternative_else_nop_endif
       stnp   x2, x3, [x0, #-256]
       ldp    x2, x3, [x1]
       stnp   x4, x5, [x0, #16 - 256]
       ldp    x4, x5, [x1, #16]
       stnp   x6, x7, [x0, #32 - 256]
       ldp    x6, x7, [x1, #32]
       stnp   x8, x9, [x0, #48 - 256]
       ldp    x8, x9, [x1, #48]
       stnp   x10, x11, [x0, #64 - 256]
       ldp    x10, x11, [x1, #64]
       stnp   x12, x13, [x0, #80 - 256]
       ldp    x12, x13, [x1, #80]
       stnp   x14, x15, [x0, #96 - 256]
       ldp    x14, x15, [x1, #96]
       stnp   x16, x17, [x0, #112 - 256]
       ldp    x16, x17, [x1, #112]
       add    x0, x0, #128
       add    x1, x1, #128
       b.ne   1b

在阅读这段代码之前,我们可以先思考一下如何用汇编语言实现从一个位置复制4K数据到另一个位置的功能。通过分析这段代码,我们可以了解到Linux内核函数的实现方式,并学习到一些新的优化技巧。

综上所述,通过限制问题域、利用并行处理、掌握优化技巧以及学习优秀的开源代码,我们能够编写出更高效、更优化的ARM汇编代码。同时,在优化过程中,我们也要注意避免一些潜在的问题,如过热和性能下降等。希望这些内容能对大家在ARM汇编编程方面有所帮助。

6. 代码分析与优化总结

6.1 优化技术对比

为了更清晰地了解各种优化技术的特点,我们可以通过以下表格进行对比:
| 优化技术 | 优点 | 缺点 | 适用场景 |
| — | — | — | — |
| 限制问题域 | 减少指令数量,提高处理速度 | 适用范围窄,只能处理特定类型的数据 | 处理特定类型数据,如仅处理字母字符 |
| SIMD并行处理 | 利用多核优势,大幅提高处理效率 | 代码复杂度高,需要特定硬件支持 | 处理大量数据,如字符串转换 |
| 避免分支指令 | 减少CPU开销,提高性能 | 可能增加代码复杂度 | 对性能要求高的场景 |
| 避免昂贵指令 | 减少时钟周期,提高执行速度 | 可能需要更复杂的算法 | 频繁使用乘法、除法等指令的场景 |
| 使用宏 | 消除函数调用开销 | 可能增加代码体积 | 函数调用频繁且参数传递复杂的场景 |
| 循环展开 | 节省循环控制指令开销 | 增加代码量,可能导致缓存命中率下降 | 循环次数固定且较少的场景 |
| 保持数据量小 | 减轻内存总线压力 | 可能需要更复杂的数据处理逻辑 | 内存带宽有限的场景 |

6.2 优化流程

为了更好地应用这些优化技术,我们可以遵循以下优化流程:

graph LR
    A[确定优化目标] --> B[分析代码瓶颈]
    B --> C{选择优化技术}
    C -->|限制问题域| D1[缩小处理范围]
    C -->|SIMD并行处理| D2[利用多核优势]
    C -->|避免分支指令| D3[减少条件判断]
    C -->|避免昂贵指令| D4[替换高开销指令]
    C -->|使用宏| D5[消除函数调用开销]
    C -->|循环展开| D6[重复循环代码]
    C -->|保持数据量小| D7[减少数据移动]
    D1 --> E[实现优化代码]
    D2 --> E
    D3 --> E
    D4 --> E
    D5 --> E
    D6 --> E
    D7 --> E
    E --> F[测试优化效果]
    F -->|未达到目标| B
    F -->|达到目标| G[完成优化]

7. 实际应用案例分析

7.1 字符串转换案例

在前面的字符串转换案例中,我们使用了限制问题域和SIMD并行处理两种优化技术。通过限制问题域,我们仅处理字母字符,省去了范围比较操作;通过SIMD并行处理,我们利用NEON指令一次处理16个字符,大大提高了处理效率。

7.2 内存页面复制案例

在Linux内核的内存页面复制案例中,我们看到了如何通过预取和批量加载/存储操作来优化性能。预取操作可以提前将数据加载到缓存中,减少CPU等待时间;批量加载/存储操作可以一次处理多个数据,提高数据传输效率。

8. 总结与展望

8.1 总结

通过本文的介绍,我们了解了ARM汇编代码的优化方法和技巧,包括限制问题域、使用SIMD并行处理、避免分支指令、避免昂贵指令、使用宏、循环展开、保持数据量小等。同时,我们还学习了如何浏览和分析开源代码,以及如何应用这些优化技术到实际项目中。

8.2 展望

随着硬件技术的不断发展,ARM处理器的性能将不断提高,同时也将支持更多的优化指令和技术。未来,我们可以进一步探索如何利用这些新技术来优化ARM汇编代码,提高程序的性能和效率。此外,我们还可以结合其他编程语言和工具,如C、Python等,来实现更复杂的功能和优化。

总之,ARM汇编代码优化是一个不断发展和探索的领域,需要我们不断学习和实践,才能编写出更高效、更优化的代码。希望本文能为大家在ARM汇编编程方面提供一些帮助和启示。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值