这个问题的变体一直被问到。这个版本的问题(需要if(isalpha(c)) c|=0x20; 之外的条件行为)使问题变得足够复杂,以至于如何有效地解决问题并不是很明显。
事实证明,xor 并不难想到,并且将此代码无条件地转换为大写或小写只需要从 xor 0x20 到 and ~0x20 或 or 0x20 的简单更改。 (也可以再简化一点。)
这就是我会尝试最佳高效汇编的方式。我什至包含了一个带有 SIMD 向量的版本,以及另一个版本的字节循环,它使用了我从向量化中得到的无分支想法。
只有在您了解了使用未优化代码解决此问题所涉及的基本原则后,阅读此答案可能才有用。 OTOH,实际需要的操作很少,所以没有太多代码可以理解。我确实对它进行了大量评论。 x86 标签 wiki 中有许多有用的链接,从教程到参考指南再到性能调整。
小写和大写字母 ASCII 字符之间的转换只需要设置或清除 0x20 位,因为 ASCII 字符集的布局是彼此相距 32 的范围,并且不跨越 mod32 边界。
对于每个字节:
- 制作一个副本并无条件地用 0x20 或它
- 检查它是否在
'a'和'z'之间
- 如果是这样,使用
xor 翻转 ASCII 字母大小写位并将结果存储回数组中。
以这种方式进行 ASCII isalpha(3) 测试是安全的:从设置该位到 'a'..'z' 范围内的唯一源字节是大写字母字符。这只是适用于不跨越%32 边界的任何两个大小相等的范围的数学。 (例如,如果相关位为 0x40,则为 %64 边界)。
为了更有效地进行比较,我使用了无符号比较技巧,因此循环内只有一个条件分支(循环条件本身除外)。解释见代码中的 cmets。
一次一个字节,对字母字符检测进行有效的范围检查
/******** Untested. ************/
// ASCII characters are flipped to the opposite case (upper <-> lower)
// non-ASCII characters are left unchanged
void changeCase (char char_array[], int array_size ) {
__asm{
// BEGIN YOUR CODE HERE
mov esi, char_array; // MSVC inline asm requires these potentially-redundant copies :(
mov ecx, array_size;
test ecx,ecx; // return if(size <= 0)
jle early_out;
next_char:
movzx eax, byte ptr [esi]; // load the current character
mov edx, eax; // save a copy to maybe flip + store
// check if the character is alphabetic or not
// there are two equal-size ranges of characters: one with 0x20 set, and one without
or al, 0x20; // set 0x20 and then just check that lowercase range
// unsigned compare trick: 0 <= n < high can be done with one unsigned compare instead of two signed compares
// low < n < high can be done by shifting the range first
sub al, 'a'; // if al is less than 'a', it will become a large unsigned number
cmp al, 'z'-'a';
ja non_alpha; // conditionally skip the flip & store
xor dl, 0x20; // toggle the ASCII case bit
mov [esi], dl;
// xor [esi], 0x20 // saves the mov earlier, but is otherwise slower
non_alpha:
inc esi;
dec ecx;
jz next_char;
early_out:
// END YOUR CODE HERE
}
}
如果某些“设计文档”内容位于代码之外的块中,则此代码可能更具可读性。它把事情弄得乱七八糟,看起来有很多代码,但实际上指令很少。 (它们只是很难用简短的 cmets 来解释。注释代码很棘手:过于明显的 cmets 只会造成混乱,并且会占用阅读代码和有用 cmets 的时间。)
矢量化
实际上对于 x86,我会使用 SSE 或 AVX 一次执行 16B,执行相同的算法,但与两个 pcmpgtb 进行比较。当然,无条件地存储结果,所以所有非字母字符的数组仍然会在缓存中被弄脏,使用更多的内存带宽。
没有无符号的 SSE 比较,但我们仍然可以将我们正在寻找的范围向下移动到底部。没有小于-128 的值,因此在有符号比较中它的工作方式与0 在无符号比较中的工作方式相同。
为此,减去128。 (or add, or xor (carryless add); there's nowhere for the carry / borrow to go)。这可以在与减去'a' 相同的操作中完成。
然后使用比较结果作为掩码将0x20 向量中的字节清零,因此只有字母字符与0x20 进行异或。 (0 是 XOR/add/sub 的标识元素,这对于 SIMD 条件通常非常方便)。
另请参阅strtoupper version that has been tested 和在循环中调用它的代码,包括在隐式长度 C 字符串上处理非 16 倍数的输入(搜索终止的 0在飞行中)。
#include <immintrin.h>
// Call this function in a loop, with scalar cleanup. (Not implemented, since it's the same as any other vector loop.)
// Flip the case of all alphabetic ASCII bytes in src
__m128i inline flipcase(__m128i src) {
// subtract 'a'+128, so the alphabetic characters range from -128 to -128+25 (-128+'z'-'a')
// note that adding 128 and subtracting 128 are the same thing for 8bit integers.
// There's nowhere for the carry to go, so it's just xor (carryless add), flipping the high bit
__m128i lcase = _mm_or_si128(src, _mm_set1_epi8(0x20));
__m128i rangeshift= _mm_sub_epi8(lcase, _mm_set1_epi8('a'+128));
__m128i non_alpha = _mm_cmpgt_epi8(rangeshift, _mm_set1_epi8(-128 + 25)); // 0:alphabetic -1:non-alphabetic
__m128i flip = _mm_andnot_si128(non_alpha, _mm_set1_epi8(0x20)); // 0x20:alpha 0:non-alpha
return _mm_xor_si128(src, flip);
// just mask the XOR-mask so non-alphabetic elements are XORed with 0 instead of 0x20
// XOR's identity value is 0, same as for addition
}
这个compiles to nice code, even without AVX,只有一个额外的movdqa 来保存一个寄存器的副本。查看两个早期版本的 godbolt 链接(一个使用两个比较以保持简单,另一个使用 pblendvb,然后我记得屏蔽 0x20s 的向量而不是结果。)
flipcase:
movdqa xmm2, XMMWORD PTR .LC0[rip] ; 0x20
movdqa xmm1, xmm0
por xmm1, xmm2
psubb xmm1, XMMWORD PTR .LC1[rip] ; -31
pcmpgtb xmm1, XMMWORD PTR .LC2[rip] ; -103
pandn xmm1, xmm2
pxor xmm0, xmm1
ret
section .rodata
.LC0: times 16 db 32
.LC1: times 16 db -31
.LC2: times 16 db -103
使用无分支测试的同样想法也适用于字节循环:
mov esi, char_array;
mov ecx, array_size;
test ecx,ecx; // return if(size <= 0)
jle .early_out;
ALIGN 16 ; really only need align 8 here, since the next 4 instructions are all 2 bytes each (because op al, imm8 insns have a special encoding)
.next_char:
movzx eax, byte ptr [esi]; // load the current character
mov edx, eax;
// check if the character is alphabetic or not
or al, 0x20;
sub al, 'a';
cmp al, 'z'-'a'; // unsigned compare trick: 'a' <= al <= 'z'
setna al; // 0:non-alpha 1:alpha (not above)
shl al, 5; // 0:non-alpha 0x20:alpha
xor dl, al; // conditionally toggle the ASCII case bit
mov [esi], dl; // unconditionally store
inc esi;
dec ecx; // for AMD CPUs, or older Intel, it would be better to compare esi against an end pointer, since cmp/jz can fuse but dec can't. This saves an add ecx, esi outside the loop
jz .next_char;
.early_out:
对于 64 位代码,只需使用 rsi 而不是 esi。其他一切都一样。
显然是MSVC inline asm doesn't allow .label local-symbol names。我将它们更改为第一个版本(带有条件分支),但不是这个。
使用movzx eax, byte [esi] 优于mov al, [esi],避免了对AMD、Intel Haswell 及更高版本以及Silvermont 系列的错误依赖。 movzx 并不像旧 AMD 的负载那么便宜。 (它至少在 Intel 和 AMD Ryzen 上,一个只使用加载端口,而不是 ALU 端口的微指令)。 Why doesn't GCC use partial registers?
之后在al 上运行仍然可以。没有partial-register stall(或避免它的额外说明),因为在setcc 写入al 之后我们没有读取eax。 (可惜没有setcc r/m32,只有r/m8)。
我想知道如果有人为这样的任务提交这样的代码,教授会怎么想。 :P 我怀疑即使是智能编译器也会使用 setcc / shift 技巧,除非您将编译器引向它。 (可能是unsigned mask = (tmp>='a' && tmp<='z'); mask <<= 5; a[i] ^= mask; 或其他东西。)编译器确实知道无符号比较技巧,但gcc doesn't use it in some cases for non-compile-time-constant range checks, even when it can prove that the range is small enough。