PHP的類名,函數,方法名是不區分大小寫的,也就是說無論你怎麼定義函數名,實際上在引擎層面查找的時候都是會統一轉換成小寫形式來做的。 也就是說strtolower的應用是非常普遍的。

當然,PHP也做了很多的設計來避免對字符串做過多的字符串小寫操作,比如如果我們在PHP代碼中寫下:

CamelFunc();

這樣的函數調用的時候, PHP會在編譯時刻就把CamelFunc全部小寫,然後存儲在原始字面量之後(PHP-5.4 literals)。

但不管怎麼說,還是不能完全避免對strtolower的調用,比如動態名字的時候。

所以如果能提升strtolower的性能的話,還是會很有益處的。

之前我分享過如何用SSE2指令來做字符替換,今天來分享下PHP8中使用SSE2指令來做locale無關的strtolower的較高效實現,strtoupper相對來說也會類似。

當然,有同學可能會疑問爲啥不是SSE4,或者AVX,最根本的原因是SSE2的支持度更廣泛,基本上可以說現在的x86架構的CPU沒有不支持的。相對來說,不需要考慮太多runtime switch,否則會導致代碼變的很複雜,這塊大家可以參考我之前給PHP7做的base64 encode/decocde的SIMD優化。

回到正題,我們首先來看看ASCII碼錶,很容易的能發現:

a-z和A-Z都是分別連續編碼的,a和A的編碼值相差32(十進制), 所以其實strtolower如果不考慮locale的話,基本就是:

  • 確定一個字符是大寫字母, 因爲如果不是大寫字母,你給它加32的話,它的意義就變了.
  • 給這個字符加上32, 這個字符就變成了小寫形式

傳統的做法是一個字符個字符來判斷它是否是大寫字母,然後做變換,而在PHP8之前,我們是採用碼錶實現的,也就是給256 Range的字符都給定一個對應的’lower’編碼值,直接查表即可,但不管怎麼說,這些都需要一個字符一個字符的處理。

現在來看看我在PHP8中的SSE2實現:

const __m128i _A = _mm_set1_epi8('A' - 1);
const __m128i Z_ = _mm_set1_epi8('Z' + 1);
const __m128i delta = _mm_set1_epi8('a' - 'A');
do {
	__m128i op = _mm_loadu_si128((__m128i*)p);
	__m128i gt = _mm_cmpgt_epi8(op, _A);
	__m128i lt = _mm_cmplt_epi8(op, Z_);
	__m128i mingle = _mm_and_si128(gt, lt);
	__m128i add = _mm_and_si128(mingle, delta);
	__m128i lower = _mm_add_epi8(op, add);
	_mm_storeu_si128((__m128i *)q, lower);
	p += 16;
	q += 16;
} while (p + 16 <= end);

結合上面的步驟我們來看看這段代碼中幾個關鍵步驟:

  • _mm_loadu_si128: 一次性讀取16個字符進入mmx寄存器
  • _mm_cmpgt_epi8: 一條指令檢查16個字符串那些的字符值是大於’A’-1的
  • _mm_cmplt_epi8: 一條指令檢查16個字符串哪些字符的值是小於’Z’+1的
  • _mm_and_si128: 結合上面倆條的結果,最後的結果中0xff的值的位置就是大寫字符
  • _mm_add_epi8: 給所有的0xff位置的字符加上32(0x20), 完成小寫轉換

它一次能批量處理16個字符,相比原來要做16次單個字符比較,tolower的處理,性能提升會非常明顯。

不過有一點要注意的,在PHP8中,我們只是針對locale爲默認的情況下才會使用這個加速,也就是如果你使用了setlocale設置LC_TYPE爲非默認的”C”, 就不會應用這個優化。

好了,現在看起來我講完了,文章是不是也應該結束了呢?

並不是!

考慮上面的代碼,我們有沒有辦法進一步優化呢?

我在Yaf框架中,其實使用了一個稍微更巧妙的改進, 核心的變化在:

const __m128i upper_guard = _mm_set1_epi8('A' + 128);
__m128i in = _mm_loadu_si128((__m128i*)str);
rot = _mm_sub_epi8(in, upper_guard);
upper = _mm_cmpgt_epi8(rot, _mm_set1_epi8(-128 + 'Z' - 'A'));

如上面的代碼所示,首先我們把所有讀進來的字符,統一減去(減去)’A' + 128,此時如果對於’A’來說,它的值是-128,也就是8位bits最小的值-128.

此時只要是小於等於-128 + ‘Z’ - ‘A’的字符,就都是大寫字符了。

這段代碼取代了原來的需要兩次比較合併結果的方法,只需要一次比較,就可以確定哪些字符是大寫字符了,後續的邏輯都一樣了,給大寫字母加上32即可,是不是就比較巧妙了?

不過,我並沒有把這個版本的實現merge到PHP8中,只是在Yaf中應用了,PHP8中還是保留了原來倆次比較的方法,主要的原因還是因爲這個方法相對來說理解起來有點困難,性能提升也不明顯,爲了代碼邏輯清晰易懂。

好了,到這裏,本篇文章就真的結束了, byebye

相關文章