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汇编编程方面提供一些帮助和启示。
超级会员免费看
74

被折叠的 条评论
为什么被折叠?



