有没有更有效的方法来获取以字节为单位的 32 位整数的长度?
Posted
技术标签:
【中文标题】有没有更有效的方法来获取以字节为单位的 32 位整数的长度?【英文标题】:Is there a more efficient way to get the length of a 32bit integer in bytes? 【发布时间】:2010-08-30 16:02:49 【问题描述】:我想要以下小功能的快捷方式,其中 性能非常重要(该函数被调用超过 10.000.000 次):
inline int len(uint32 val)
if(val <= 0x000000ff) return 1;
if(val <= 0x0000ffff) return 2;
if(val <= 0x00ffffff) return 3;
return 4;
有谁知道...一个很酷的位操作技巧? 提前感谢您的帮助!
【问题讨论】:
我怀疑这可以做得更快。 哇!超过1000万次……你的意思是,如果从这个函数中挤出三个周期,最多可以节省0.03s? 右移并测试零标志可能会在 AVR 8 位上节省高达 1 个周期;这种优化实际上可能很重要。但我怀疑这个问题是针对台式电脑的。 0 怎么样:它的大小不是 0 吗? 10,000,000 次是什么?分钟?小时?我怀疑你会从进一步优化中获得很多好处。正如已经建议的那样,您需要对应用程序进行概要分析才能确定。 【参考方案1】:这个怎么样?
inline int len(uint32 val)
return 4
- ((val & 0xff000000) == 0)
- ((val & 0xffff0000) == 0)
- ((val & 0xffffff00) == 0)
;
删除 inline
关键字,g++ -O2
将其编译为以下无分支代码:
movl 8(%ebp), %edx
movl %edx, %eax
andl $-16777216, %eax
cmpl $1, %eax
sbbl %eax, %eax
addl $4, %eax
xorl %ecx, %ecx
testl $-65536, %edx
sete %cl
subl %ecx, %eax
andl $-256, %edx
sete %dl
movzbl %dl, %edx
subl %edx, %eax
如果您不介意特定于机器的解决方案,您可以使用搜索前 1 位的 bsr
指令。然后您只需除以 8 即可将位转换为字节并加 1 以将范围 0..3 移动到 1..4:
int len(uint32 val)
asm("mov 8(%ebp), %eax");
asm("or $255, %eax");
asm("bsr %eax, %eax");
asm("shr $3, %eax");
asm("inc %eax");
asm("mov %eax, 8(%ebp)");
return val;
请注意,我不是内联汇编之神,所以也许有更好的解决方案来访问 val
而不是显式寻址堆栈。但是你应该明白基本的想法。
GNU 编译器还有一个有趣的内置函数,称为__builtin_clz
:
inline int len(uint32 val)
return ((__builtin_clz(val | 255) ^ 31) >> 3) + 1;
在我看来,这比内联汇编版本好得多:)
【讨论】:
+1:我写的是/和+的版本,但是思路是一样的 @youllknow:它删除了所有比较,使代码线性化。这通过消除任何可能的分支错误预测来帮助指令流水线。 虽然所有的分支都可能被这段代码删除(可能,因为它还很不确定)你在测试之间引入了依赖关系,在这种情况下你将始终拥有 3 和。 这将始终执行相同数量的操作,而与目标无关。级联比较和返回结果应该会产生一半的聚合操作,假设函数的目标是均匀的。 @stef:请注意,i/8
和 i>>3
在给定负输入的情况下会产生不同的结果,因此如果 i
是签名类型!例如,(-1)/8 = 0
但(-1)>>3 = -1
。这就是为什么我明确写了>>3
而不是/8
。我知道__builtin_clz
的结果总是肯定的,但是编译器不知道。【参考方案2】:
我做了一个不科学的小型基准测试,只是测量在 VS 2010 编译器下从 0 到 MAX_LONG 次循环调用函数时 GetTickCount() 调用的差异。
这是我看到的:
这需要 11497 个滴答声
inline int len(uint32 val)
if(val <= 0x000000ff) return 1;
if(val <= 0x0000ffff) return 2;
if(val <= 0x00ffffff) return 3;
return 4;
虽然这需要 14399 个滴答声
inline int len(uint32 val)
return 4
- ((val & 0xff000000) == 0)
- ((val & 0xffff0000) == 0)
- ((val & 0xffffff00) == 0)
;
编辑:我关于为什么更快的想法是错误的,因为:
inline int len(uint32 val)
return 1
+ (val > 0x000000ff)
+ (val > 0x0000ffff)
+ (val > 0x00ffffff)
;
这个版本只使用了 11107 个刻度。因为 + 可能比 - 快?我不确定。
7161 滴答时的二分搜索甚至更快
inline int len(uint32 val)
if (val & 0xffff0000) return (val & 0xff000000)? 4: 3;
return (val & 0x0000ff00)? 2: 1;
目前最快的是使用 MS 内在函数,在 4399 滴答声
#pragma intrinsic(_BitScanReverse)
inline int len2(uint32 val)
DWORD index;
_BitScanReverse(&index, val);
return (index>>3)+1;
供参考 - 这是我用来分析的代码:
int _tmain(int argc, _TCHAR* argv[])
int j = 0;
DWORD t1,t2;
t1 = GetTickCount();
for(ULONG i=0; i<-1; i++)
j=len(i);
t2 = GetTickCount();
_tprintf(_T("%ld ticks %ld\n"), t2-t1, j);
t1 = GetTickCount();
for(ULONG i=0; i<-1; i++)
j=len2(i);
t2 = GetTickCount();
_tprintf(_T("%ld ticks %ld\n"), t2-t1, j);
必须打印 j 以防止循环被优化。
【讨论】:
您使用了哪些优化设置?编译器应该仍然消除了循环。使用j += len(i);
而不是j = len(i);
以防止编译器将其替换为j = len(~0UL);
+1 用于测量。为什么没有其他人这样做?我不关心任何组装技巧,如果有人没有证明这些技巧有用的话。
@ben 更改为 j+= 使所有实现执行得更快 - 我不明白为什么完全
如果你让 'j' 易失,循环不会被优化出来。
根据实际数据的“形状”,您的测试可能会偏向于有分支的版本。因为您的测试会产生“块”的结果(首先所有长度 = 1,然后长度 = 2 等),所以 CPU 中的分支预测器大部分时间都是正确的。如果要测量的数据在很大程度上是可预测的,这是公平的,但如果输入数据是随机的,则基于分支的解决方案将降级,而非分支解决方案将具有一致的性能。【参考方案3】:
您是否真的有档案证据表明这是您的应用程序中的一个重大瓶颈?只需以最明显的方式进行,并且只有在分析表明这是一个问题(我对此表示怀疑)时,然后尝试改进事情。与更改其中的某些内容相比,减少对该函数的调用次数很可能会获得最好的改进。
【讨论】:
【参考方案4】:二分查找可能会节省几个周期,具体取决于处理器架构。
inline int len(uint32 val)
if (val & 0xffff0000) return (val & 0xff000000)? 4: 3;
return (val & 0x0000ff00)? 2: 1;
或者,如果大多数输入都是一个字节(例如,在构建 UTF-8 编码时,但您的断点不会是 32/24/),那么找出最常见的情况可能会降低平均周期数16/8):
inline int len(uint32 val)
if (val & 0xffffff00)
if (val & 0xffff0000)
if (val & 0xff000000) return 4;
return 3;
return 2;
return 1;
现在,short case 进行的条件测试最少。
【讨论】:
+1 -- 我刚开始写关于使用二分搜索的文章。【参考方案5】:如果位操作比在您的目标机器上进行比较快,您可以这样做:
inline int len(uint32 val)
if(val & 0xff000000) return 4;
if(val & 0x00ff0000) return 3;
if(val & 0x0000ff00) return 2;
return 1;
【讨论】:
Bitops 或 compares 在大多数 CPU 上是相同的(compare 只不过是减法),并且分支的数量是相同的。也就是说,按可能性对测试进行排序是一种好方法。 它们的结果可能相同,但是在简单的数字逻辑实现中,加法和减法确实涉及进位/借位传播,而按位运算的每个位的结果是完全独立的,因此它很可能更快使用按位与。 一个像样的编译器会自己做这样的微改变。【参考方案6】:如果您的数字分布不便于预测,您可以避免代价高昂的条件分支:
return 4 - (val <= 0x000000ff) - (val <= 0x0000ffff) - (val <= 0x00ffffff);
将<=
更改为&
在现代处理器上不会有太大变化。您的目标平台是什么?
这是为 x86-64 生成的代码,带有gcc -O
:
cmpl $255, %edi
setg %al
movzbl %al, %eax
addl $3, %eax
cmpl $65535, %edi
setle %dl
movzbl %dl, %edx
subl %edx, %eax
cmpl $16777215, %edi
setle %dl
movzbl %dl, %edx
subl %edx, %eax
当然有比较指令cmpl
,但后面是setg
或setle
,而不是条件分支(通常情况下)。在现代流水线处理器上昂贵的是条件分支,而不是比较。所以这个版本省去了昂贵的条件分支。
我尝试手动优化 gcc 的程序集:
cmpl $255, %edi
setg %al
addb $3, %al
cmpl $65535, %edi
setle %dl
subb %dl, %al
cmpl $16777215, %edi
setle %dl
subb %dl, %al
movzbl %al, %eax
【讨论】:
<=
不会仍然会导致需要分支指令吗?
任何现代英特尔处理器(core2duo、i7...)
@Mark B 不适用于大多数架构;例如,我的编译器使用 setg
和 setle
指令将比较标志传输到 x86-64 上的寄存器而无需分支。
一个分支如果被错误预测是昂贵的,否则它真的很便宜。至于设置指令,我只是查看了它的延迟/吞吐量,它是所有现代芯片上的 1 个周期延迟(P4 有 5 个周期),每个周期的吞吐量为 1(除了 K7/K8,每个周期可以做 2 或 3 个) )
@tristopia 代价高昂的是在写入部分寄存器后不到几十个周期内尝试读取完整寄存器。这仍然留下了一些创造性的用途,但这不是其中之一:我从不读取由部分写入组成的完整寄存器。为了避免这种情况,我将movzbl
保留在末尾。【参考方案7】:
根据您的架构,您可能有更有效的解决方案。
MIPS 有一个“CLZ”指令,用于计算数字的前导零位的数量。您在这里寻找的基本上是4 - (CLZ(x) / 8)
(其中/
是整数除法)。 PowerPC 具有等效指令cntlz
,x86 具有BSR
。此解决方案应简化为 3-4 条指令(不计算函数调用开销)和零个分支。
【讨论】:
我刚刚注意到BSR
的运作方式与CLZ
略有不同。 BSR
返回一个位索引,因此对于 32 位输入,您必须执行 31 - BSR(x)
才能产生等效于 MIPS CLZ
指令。【参考方案8】:
在某些系统上,这在某些架构上可能会更快:
inline int len(uint32_t val)
return (int)( log(val) / log(256) ); // this is the log base 256 of val
这也可能稍微快一些(如果比较时间比按位和长):
inline int len(uint32_t val)
if (val & ~0x00FFffFF)
return 4;
if (val & ~0x0000ffFF)
return 3;
if (val & ~0x000000FF)
return 2;
return 1;
如果您使用的是 8 位微控制器(例如 8051 或 AVR),那么这将是最好的:
inline int len(uint32_t val)
union int_char
uint32_t u;
uint8_t a[4];
x;
x.u = val; // doing it this way rather than taking the address of val often prevents
// the compiler from doing dumb things.
if (x.a[0])
return 4;
else if (x.a[1])
return 3;
...
由 tristopia 编辑:最后一个变体的字节序感知版本
int len(uint32_t val)
union int_char
uint32_t u;
uint8_t a[4];
x;
const uint16_t w = 1;
x.u = val;
if( ((uint8_t *)&w)[1]) // BIG ENDIAN (Sparc, m68k, ARM, Power)
if(x.a[0]) return 4;
if(x.a[1]) return 3;
if(x.a[2]) return 2;
else // LITTLE ENDIAN (x86, 8051, ARM)
if(x.a[3]) return 4;
if(x.a[2]) return 3;
if(x.a[1]) return 2;
return 1;
由于 const,任何称职的编译器都只会生成正确字节序的代码。
【讨论】:
@vonbrand:是的,对于最后一种方法,系统上使用的字节序很重要。 你可以让它非常简单地感知字节序。我将代码添加到您的答案中。如果你不喜欢,你可以删除它。【参考方案9】:致 Pascal Cuoq 和其他 35 位支持他的评论的人:
“哇!超过1000万次……你的意思是,如果把这个函数挤出三个周期,最多可以节省0.03s?”
这样的讽刺评论充其量是粗鲁和冒犯的。
优化往往是这里3%,那里2%的累积结果。 3% 的总容量没什么可小觑。假设这是管道中几乎饱和且不可比拟的阶段。假设 CPU 利用率从 99% 上升到 96%。简单的排队理论告诉人们,CPU 利用率的这种降低将使平均队列长度减少 75% 以上。 [定性(负载除以1-负载)]
这种减少经常会导致或破坏特定的硬件配置,因为这会对内存需求、缓存排队项目、锁护送以及(如果它是分页系统会很可怕)甚至分页产生反馈影响。正是这些影响导致了分叉的磁滞回线类型的系统行为。
任何东西的到货率似乎都会上升,而现场更换特定 CPU 或购买更快的机器通常不是一种选择。
优化不仅仅是桌面上的挂钟时间。任何认为它是计算机程序行为的测量和建模的人都有很多阅读工作要做。
Pascal Cuoq 欠原海报一个道歉。
【讨论】:
@cedric H - 也许应该是,但似乎该站点软件不会让新成员评论除他自己的帖子之外的任何内容。 我不认为使用“标志”按钮有任何最低限度的声誉,如果我像你一样被冒犯,我会这样做。 实际上,我认为重要的是要指出,获得 3% 的容量提升与 0.03 秒的绝对提升有些不同,特别是因为一些 cmets 似乎不理解这种情况。 -细微的差别。也许如果您曾经必须在现场支持 100000 个板,并且板上运行的应用程序的到达率每 2 年翻一番,您可能会真正体会到这种差异。【参考方案10】:只是为了说明,基于 FredOverflow 的回答(这是一个很好的工作,赞和 +1),关于 x86 上的分支的常见缺陷。这是 gcc 输出的 FredOverflow 程序集:
movl 8(%ebp), %edx #1/.5
movl %edx, %eax #1/.5
andl $-16777216, %eax#1/.5
cmpl $1, %eax #1/.5
sbbl %eax, %eax #8/6
addl $4, %eax #1/.5
xorl %ecx, %ecx #1/.5
testl $-65536, %edx #1/.5
sete %cl #5
subl %ecx, %eax #1/.5
andl $-256, %edx #1/.5
sete %dl #5
movzbl %dl, %edx #1/.5
subl %edx, %eax #1/.5
# sum total: 29/21.5 cycles
(延迟,以周期为单位,读作 Prescott/Northwood)
Pascal Cuoq 的手工优化组装(也值得称赞):
cmpl $255, %edi #1/.5
setg %al #5
addb $3, %al #1/.5
cmpl $65535, %edi #1/.5
setle %dl #5
subb %dl, %al #1/.5
cmpl $16777215, %edi #1/.5
setle %dl #5
subb %dl, %al #1/.5
movzbl %al, %eax #1/.5
# sum total: 22/18.5 cycles
编辑:FredOverflow 使用__builtin_clz()
的解决方案:
movl 8(%ebp), %eax #1/.5
popl %ebp #1.5
orb $-1, %al #1/.5
bsrl %eax, %eax #16/8
sarl $3, %eax #1/4
addl $1, %eax #1/.5
ret
# sum total: 20/13.5 cycles
以及代码的 gcc 程序集:
movl $1, %eax #1/.5
movl %esp, %ebp #1/.5
movl 8(%ebp), %edx #1/.5
cmpl $255, %edx #1/.5
jbe .L3 #up to 9 cycles
cmpl $65535, %edx #1/.5
movb $2, %al #1/.5
jbe .L3 #up to 9 cycles
cmpl $16777216, %edx #1/.5
sbbl %eax, %eax #8/6
addl $4, %eax #1/.5
.L3:
ret
# sum total: 16/10 cycles - 34/28 cycles
作为jcc
指令的副作用的指令缓存行在其中提取可能对于这么短的函数没有任何成本。
分支可能是一个合理的选择,具体取决于输入分布。
编辑:添加了 FredOverflow 使用 __builtin_clz()
的解决方案。
【讨论】:
有趣,你能不能也测量一下我的return ((__builtin_clz(val | 255) ^ 31) >> 3) + 1;
解决方案?
你的周期来源是什么?
英特尔架构手册,siyobik.info/index.php?module=x86,以及对上述内核的测量。
@mfukar 我对你的推理有点困惑。您是否显示了条件跳转被计为零的成本估算,然后得出结论认为条件跳转不是很昂贵?这不是循环推理吗?条件分支可以很好地预测到在常规算法(例如矩阵乘法)中是免费的,但我们甚至还没有开始在这里讨论输入分布,而且它们不会对所有分布都是免费的。
@mfukar 关于在 Sylvanaar 的测试中确认“分支没有那么糟糕”,没有迹象表明编译器没有使用条件移动指令来编译 if
s。我写了一条评论,大意是任何优化此功能的尝试都可能只是为了好玩,而不是为了可衡量的差异。你看见了吗?显然这是相当有争议的。【参考方案11】:
好的,再来一个版本。与 Fred 的类似,但操作较少。
inline int len(uint32 val)
return 1
+ (val > 0x000000ff)
+ (val > 0x0000ffff)
+ (val > 0x00ffffff)
;
【讨论】:
【参考方案12】:这样可以减少比较。但如果内存访问操作的成本超过几次比较,效率可能会降低。
int precalc[1<<16];
int precalchigh[1<<16];
void doprecalc()
for(int i = 0; i < 1<<16; i++)
precalc[i] = (i < (1<<8) ? 1 : 2);
precalchigh[i] = precalc[i] + 2;
inline int len(uint32 val)
return (val & 0xffff0000 ? precalchigh[val >> 16] : precalc[val]);
【讨论】:
【参考方案13】:存储一个整数所需的最小位数是:
int minbits = (int)ceil( log10(n) / log10(2) ) ;
字节数为:
int minbytes = (int)ceil( log10(n) / log10(2) / 8 ) ;
这是一个完全受 FPU 约束的解决方案,性能可能会或可能不会优于条件测试,但也许值得研究。
[编辑] 我做了调查;上述一千万次迭代的简单循环耗时 918 毫秒,而 FredOverflow 接受的解决方案仅耗时 49 毫秒(VC++ 2010)。因此,这不是性能方面的改进,但如果它是所需的位数,可能仍然有用,并且可以进行进一步优化。
【讨论】:
【参考方案14】:如果我没记错 80x86 asm,我会这样做:
;假设 EAX 中的值;计数进入 ECX cmp eax,16777215 ;进位设置如果小于 sbb ecx,ecx ;加载 -1 如果小于,0 如果大于 cmp eax,65535 sbb ecx,0 ;少则减1; 0 如果更大 cmp eax,255 sbb ecx,-4 ;小于则加 3,大于则 4六个指令。我认为同样的方法也适用于我使用的 ARM 上的六条指令。
【讨论】:
以上是关于有没有更有效的方法来获取以字节为单位的 32 位整数的长度?的主要内容,如果未能解决你的问题,请参考以下文章
在顶点中获取 Blob / String 的大小(以字节为单位)?