<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          如何更快地將string轉(zhuǎn)換成int/long

          共 13566字,需瀏覽 28分鐘

           ·

          2021-09-13 18:16

          作者 | Kirito的技術(shù)分享

          來(lái)源 | https://mp.weixin.qq.com/s/5qQg3ef8yjVG089iqoNTaw

          你好鴨,Kirito 今天又來(lái)分享性能優(yōu)化的騷操作了。

          在很多追求性能的程序挑戰(zhàn)賽中,經(jīng)常會(huì)遇到一個(gè)操作:將 String 轉(zhuǎn)換成 Integer/Long。如果你沒(méi)有開(kāi)發(fā)過(guò)高并發(fā)的系統(tǒng),或者沒(méi)有參加過(guò)任何性能挑戰(zhàn)賽,可能會(huì)有這樣的疑問(wèn):這有啥好講究的,Integer.valueOf/Long.valueOf 又不是不能用。實(shí)際上,很多內(nèi)置的轉(zhuǎn)換工具類只滿足了功能性的需求,在高并發(fā)場(chǎng)景下,可能會(huì)是熱點(diǎn)方法,成為系統(tǒng)性能的瓶頸。

          文章開(kāi)頭,我先做一下說(shuō)明,本文的測(cè)試結(jié)論出自:https://kholdstare.github.io/technical/2020/05/26/faster-integer-parsing.html 。測(cè)試代碼基于 C++,我會(huì)在翻譯原文的同時(shí),添加了部分自己的理解,以協(xié)助讀者更好地理解其中的細(xì)節(jié)。

          問(wèn)題提出

          假設(shè)現(xiàn)在有一些文本信息,固定長(zhǎng)度為 16 位,例如下文給出的時(shí)間戳,需要盡可能快地解析這些時(shí)間戳

          timestamp
          1585201087123567
          1585201087123585
          1585201087123621

          方法體如下所示:

          std::uint64_t parse_timestamp(std::string_view s)
          {
            // ???
          }

          問(wèn)題提出后,大家不妨先思考下,如果是你,你會(huì)采取什么方案呢?帶著這樣的思考,我們進(jìn)入下面的一個(gè)個(gè)方案。

          Native 方案

          我們有哪些現(xiàn)成的轉(zhuǎn)換方案呢?

          • 繼承自 C 的 std::atoll
          • std::stringstream
          • C++17 提供的 charconv
          • boost::spirit::qi
          • 如果您正在學(xué)習(xí)Spring Boot,推薦一個(gè)連載多年還在繼續(xù)更新的免費(fèi)教程:http://blog.didispace.com/spring-boot-learning-2x/

          評(píng)測(cè)程序采用 Google Benchmark 進(jìn)行對(duì)比評(píng)測(cè)。同時(shí),我們以不做任何轉(zhuǎn)換的方案來(lái)充當(dāng) baseline,以供對(duì)比。(baseline 方案在底層,相當(dāng)于將數(shù)值放進(jìn)來(lái)了寄存器中,所以命名成了 BM_mov)

          下面給出的評(píng)測(cè)代碼不是那么地關(guān)鍵,只是為了給大家展示評(píng)測(cè)是如何運(yùn)行的。

          static void BM_mov(benchmark::State& state) {
            for (auto _ : state) {
              benchmark::DoNotOptimize(1585201087123789);
            }
          }

          static void BM_atoll(benchmark::State& state) {
            for (auto _ : state) {
              benchmark::DoNotOptimize(std::atoll(example_timestamp));
            }
          }

          static void BM_sstream(benchmark::State& state) {
            std::stringstream s(example_timestamp);
            for (auto _ : state) {
              s.seekg(0);
              std::uint64_t i = 0;
              s >> i;
              benchmark::DoNotOptimize(i);
            }
          }
          static void BM_charconv(benchmark::State& state) {
            auto s = example_timestamp;
            for (auto _ : state) {
              std::uint64_t result = 0;
              std::from_chars(s.data(), s.data() + s.size(), result);
              benchmark::DoNotOptimize(result);
            }
          }

          static void BM_boost_spirit(benchmark::State& state) {
            using boost::spirit::qi::parse;
            for (auto _ : state) {
              std::uint64_t result = 0;
              parse(s.data(), s.data() + s.size(), result);
              benchmark::DoNotOptimize(result);
            }
          }

          Native

          可以發(fā)現(xiàn) stringstream 表現(xiàn)的非常差。當(dāng)然,這并不是一個(gè)公平的比較,但從測(cè)評(píng)結(jié)果來(lái)看,使用 stringstream 來(lái)實(shí)現(xiàn)數(shù)值轉(zhuǎn)換相比 baseline 慢了 391 倍。相比之下, <charconv> 和 boost::spirit 表現(xiàn)的更好。

          既然我們已經(jīng)知道了目標(biāo)字符串包含了要解析的數(shù)字,而且不需要做任何的數(shù)值校驗(yàn),基于這些前提,我們可以思考下,還有更快的方案嗎?

          如果您正在學(xué)習(xí)Spring Boot,推薦一個(gè)連載多年還在繼續(xù)更新的免費(fèi)教程:http://blog.didispace.com/spring-boot-learning-2x/

          Naive 方案

          我們可以通過(guò)一個(gè)再簡(jiǎn)單不過(guò)的循環(huán)方案,一個(gè)個(gè)地解析字符。

          inline std::uint64_t parse_naive(std::string_view s) noexcept
          {
            std::uint64_t result = 0;
            for(char digit : s)
            {
              result *= 10;
              result += digit - '0';
            }
            return result;
          }

          Naive

          雖然這層 for 循環(huán)看起來(lái)呆呆的,但如果這樣一個(gè)呆呆的解決方案能夠擊敗標(biāo)準(zhǔn)庫(kù)實(shí)現(xiàn),何樂(lè)而不為呢?前提是,標(biāo)準(zhǔn)庫(kù)的實(shí)現(xiàn)考慮了異常場(chǎng)景,做了一些校驗(yàn),這種 for 循環(huán)寫法的一個(gè)前提是,我們的輸入一定是合理的。

          之前我的文章也提到過(guò)這個(gè)方案。顯然, naive 的方案之后還會(huì)有更優(yōu)的替代方案。

          循環(huán)展開(kāi)方案

          記得我們?cè)谖恼碌拈_(kāi)頭加了一個(gè)限定,限定了字符串長(zhǎng)度固定是 16 位,所以循環(huán)是可以被省略的,循環(huán)展開(kāi)之后,方案可以更快。

          inline std::uint64_t parse_unrolled(std::string_view s) noexcept
          {
            std::uint64_t result = 0;

            result += (s[0] - '0') * 1000000000000000ULL;
            result += (s[1] - '0') * 100000000000000ULL;
            result += (s[2] - '0') * 10000000000000ULL;
            result += (s[3] - '0') * 1000000000000ULL;
            result += (s[4] - '0') * 100000000000ULL;
            result += (s[5] - '0') * 10000000000ULL;
            result += (s[6] - '0') * 1000000000ULL;
            result += (s[7] - '0') * 100000000ULL;
            result += (s[8] - '0') * 10000000ULL;
            result += (s[9] - '0') * 1000000ULL;
            result += (s[10] - '0') * 100000ULL;
            result += (s[11] - '0') * 10000ULL;
            result += (s[12] - '0') * 1000ULL;
            result += (s[13] - '0') * 100ULL;
            result += (s[14] - '0') * 10ULL;
            result += (s[15] - '0');

            return result;
          }

          unrolled

          關(guān)于循環(huán)展開(kāi)為什么會(huì)更快,可以參考我過(guò)去關(guān)于 JMH 的文章。

          byteswap 方案

          先思考下,如果繼續(xù)圍繞上述的方案進(jìn)行,我們可能只有兩個(gè)方向:

          1. 并發(fā)執(zhí)行加法和乘法計(jì)算,但這種 CPU 操作似乎又不能通過(guò)多線程之類的手段進(jìn)行加速,該如何優(yōu)化是個(gè)問(wèn)題
          2. 將乘法和加法運(yùn)算轉(zhuǎn)換成位運(yùn)算,獲得更快的 CPU 執(zhí)行速度,但如果轉(zhuǎn)換又是個(gè)問(wèn)題

          相信讀者們都會(huì)有這樣的疑問(wèn),那我們繼續(xù)帶著這樣疑問(wèn)往下看原作者的優(yōu)化思路是什么。

          如果您正在學(xué)習(xí)Spring Boot,推薦一個(gè)連載多年還在繼續(xù)更新的免費(fèi)教程:http://blog.didispace.com/spring-boot-learning-2x/

          緊接著上述的循環(huán)展開(kāi)方案,將 “1234” 解析為 32 位整數(shù)對(duì)應(yīng)的循環(huán)展開(kāi)操作繪制為圖,過(guò)程如下:

          Unrolled solution graph

          我們可以看到,乘法和加法的操作次數(shù)跟字符的數(shù)量是線性相關(guān)的。由于每一次乘法都是由不同的乘數(shù)進(jìn)行,所以我們不能只乘“一次”,在乘法的最后,我們還需要將所有結(jié)果相加。乍一看,好像很難優(yōu)化。

          下面的優(yōu)化技巧,需要一些操作系統(tǒng)、編譯原理相關(guān)的知識(shí)作為輔助,你需要了解 byteswap 這個(gè)系統(tǒng)調(diào)用,了解大端序和小端序的字節(jié)序表示方法(后面我也會(huì)分享相關(guān)的文章),如果你不關(guān)心這些細(xì)節(jié),也可以直接跳到本段的最后,直接看結(jié)論。

          理解清楚下圖的含義,需要理解幾個(gè)概念:

          • 字符 1 對(duì)應(yīng)的 ascii 值是 31,相應(yīng)的 2 對(duì)應(yīng) 32,4 對(duì)應(yīng) 34
          • 在小端序機(jī)器上(例如 x86),字符串是以大端序存儲(chǔ)的,而 Integer 是以小端序存儲(chǔ)的
          • byteswap 可以實(shí)現(xiàn)字節(jié)序調(diào)換

          byteswap

          上圖展示了十六進(jìn)制表示下的轉(zhuǎn)換過(guò)程,可以在更少的操作下達(dá)到最終的解析狀態(tài)。

          將上圖的流程使用 C++ 來(lái)實(shí)現(xiàn),將 String 重新解釋為 Integer,必須使用 std::memcpy(避免命名沖突),執(zhí)行相減操作,然后通過(guò)編譯器內(nèi)置的 __builtin_bswap64 在一條指令中交換字節(jié)。到目前為止,這是最快的一個(gè)優(yōu)化。

          template <typename T>
          inline T get_zeros_string() noexcept;

          template <>
          inline std::uint64_t get_zeros_string<std::uint64_t>() noexcept
          {
            std::uint64_t result = 0;
            constexpr char zeros[] = "00000000";
            std::memcpy(&result, zeros, sizeof(result));
            return result;
          }

          inline std::uint64_t parse_8_chars(const char* string) noexcept
          {
            std::uint64_t chunk = 0;
            std::memcpy(&chunk, string, sizeof(chunk));
            chunk = __builtin_bswap64(chunk - get_zeros_string<std::uint64_t>());

            // ...
          }

          我們看上去得到了想要的結(jié)果,但是這個(gè)方案從時(shí)間復(fù)雜度來(lái)看,仍然是 O(n) 的,是否可以在這個(gè)方案的基礎(chǔ)上,繼續(xù)進(jìn)行優(yōu)化呢?

          分治方案

          從最初的 Native 方案,到上一節(jié)的 byteswap 方案,我們都只是優(yōu)化了 CPU 操作,并沒(méi)有優(yōu)化復(fù)雜度,既然不滿足于 O(n),那下一個(gè)復(fù)雜度可能性是什么?O(logn)!我們可以將每個(gè)相鄰的數(shù)字組合成一對(duì),然后將每對(duì)數(shù)字繼續(xù)組合成一組四個(gè),依此類推,直到我們得到整個(gè)整數(shù)。

          如何同時(shí)處理鄰近的數(shù)字,這是讓算法跑進(jìn) O(logn) 的關(guān)鍵

          該方案的關(guān)鍵之處在于:將偶數(shù)位的數(shù)字乘以 10 的冪,并且單獨(dú)留下奇數(shù)位的數(shù)字。這可以通過(guò)位掩碼(bitmasking)來(lái)實(shí)現(xiàn)

          分治方案

          通過(guò) bitmasking,我們可以一次對(duì)多個(gè)數(shù)字進(jìn)行操作,將它們組合成一個(gè)更大的組合

          通過(guò)使用這個(gè)掩碼技巧來(lái)實(shí)現(xiàn)前文提到的 parse_8_chars 函數(shù)。使用 bitmasking 的另一好處在于,我們不用減去 '0' ,因?yàn)槲谎诖a的副作用,使得我們正好可以省略這一步。

          inline std::uint64_t parse_8_chars(const char* string) noexcept
          {
            std::uint64_t chunk = 0;
            std::memcpy(&chunk, string, sizeof(chunk));

            // 1-byte mask trick (works on 4 pairs of single digits)
            std::uint64_t lower_digits = (chunk & 0x0f000f000f000f00) >> 8;
            std::uint64_t upper_digits = (chunk & 0x000f000f000f000f) * 10;
            chunk = lower_digits + upper_digits;

            // 2-byte mask trick (works on 2 pairs of two digits)
            lower_digits = (chunk & 0x00ff000000ff0000) >> 16;
            upper_digits = (chunk & 0x000000ff000000ff) * 100;
            chunk = lower_digits + upper_digits;

            // 4-byte mask trick (works on pair of four digits)
            lower_digits = (chunk & 0x0000ffff00000000) >> 32;
            upper_digits = (chunk & 0x000000000000ffff) * 10000;
            chunk = lower_digits + upper_digits;

            return chunk;
          }

          trick 方案

          綜合前面兩節(jié),解析 16 位的數(shù)字,我們將它分成兩個(gè) 8 字節(jié)的塊,運(yùn)行剛剛編寫的 parse_8_chars,并對(duì)其進(jìn)行基準(zhǔn)測(cè)試!

          inline std::uint64_t parse_trick(std::string_view s) noexcept
          {
            std::uint64_t upper_digits = parse_8_chars(s.data());
            std::uint64_t lower_digits = parse_8_chars(s.data() + 8);
            return upper_digits * 100000000 + lower_digits;
          }

          static void BM_trick(benchmark::State& state) {
            for (auto _ : state) {
              benchmark::DoNotOptimize(parse_trick(example_stringview));
            }
          }

          trick

          看上去優(yōu)化的不錯(cuò),我們將循環(huán)展開(kāi)方案的基準(zhǔn)測(cè)試優(yōu)化了近 56% 的性能。能做到這一點(diǎn),主要得益于我們手動(dòng)進(jìn)行一系列 CPU 優(yōu)化的操作,雖然這些并不是特別通用的技巧。這樣算不算開(kāi)了個(gè)不好的頭呢?我們看起來(lái)對(duì) CPU 操作干預(yù)地太多了,或許我們應(yīng)該放棄這些優(yōu)化,讓 CPU 自由地飛翔。

          SIMD trick 方案

          你是不是以為上面已經(jīng)是最終方案了呢?不,優(yōu)化還剩最后一步。

          我們已經(jīng)得到了一個(gè)結(jié)論

          • 同時(shí)組合多組數(shù)字以實(shí)現(xiàn) O(logn) 復(fù)雜度

          如果有 16 個(gè)字符或 128 位的字符串要解析,還可以使用 SIMD。感興趣的讀者可以參考SIMD stands for Single Instruction Multiple Data。Intel 和 AMD CPU 都支持 SSE 和 AVX 指令,并且它們通常使用更寬的寄存器。

          SIMA 簡(jiǎn)單來(lái)說(shuō)就是一組 CPU 的擴(kuò)展指令,可以通過(guò)調(diào)用多組寄存器實(shí)現(xiàn)并行的乘法運(yùn)算,從而提升系統(tǒng)性能。我們一般提到的向量化運(yùn)算就是 SIMA。

          讓我們先設(shè)置 16 個(gè)字節(jié)中的每一個(gè)數(shù)字:

          inline std::uint64_t parse_16_chars(const char* string) noexcept
          {
            auto chunk = _mm_lddqu_si128(
              reinterpret_cast<const __m128i*>(string)
            );
            auto zeros =  _mm_set1_epi8('0');
            chunk = chunk - zeros;
            
            // ...
          }

          現(xiàn)在,主角變成了 madd 該系統(tǒng)調(diào)用。這些 SIMD 函數(shù)與我們使用位掩碼技巧所做的操作完全一樣——它們采用同一個(gè)寬寄存器,將其解釋為一個(gè)由較小整數(shù)組成的向量,每個(gè)乘以一個(gè)特定的乘數(shù),然后將相鄰位的結(jié)果相加到一個(gè)更寬的整數(shù)向量中。所有操作一步完成。

          // The 1-byte "trick" in one instruction
          const auto mult = _mm_set_epi8(
            110110110110110110110110
          );
          chunk = _mm_maddubs_epi16(chunk, mult);

          2 字節(jié)方案其實(shí)還有另一條指令,但不幸的是我并沒(méi)有找到 4 字節(jié)方案的指令,還是需要兩條指令。這是完整的 parse_16_chars 方案:

          inline std::uint64_t parse_16_chars(const char* string) noexcept
          {
            auto chunk = _mm_lddqu_si128(
              reinterpret_cast<const __m128i*>(string)
            );
            auto zeros =  _mm_set1_epi8('0');
            chunk = chunk - zeros;

            {
              const auto mult = _mm_set_epi8(
                110110110110110110110110
              );
              chunk = _mm_maddubs_epi16(chunk, mult);
            }
            {
              const auto mult = _mm_set_epi16(1100110011001100);
              chunk = _mm_madd_epi16(chunk, mult);
            }
            {
              chunk = _mm_packus_epi32(chunk, chunk);
              const auto mult = _mm_set_epi16(0000110000110000);
              chunk = _mm_madd_epi16(chunk, mult);
            }

            return ((chunk[0] & 0xffffffff) * 100000000) + (chunk[0] >> 32);
          }

          SIMD trick

          0.75 nanoseconds! 是不是大吃一驚呢.

          總結(jié)

          整體對(duì)比

          有人可能會(huì)問(wèn),你為啥要用 C++ 來(lái)介紹下,不能用 Java 嗎?我再補(bǔ)充下,本文的測(cè)試結(jié)論,均來(lái)自于老外的文章,文章出處見(jiàn)開(kāi)頭,其次,本文的后半部分的優(yōu)化,都是基于一些系統(tǒng)調(diào)用,和 CPU 指令的優(yōu)化,這些在 C++ 中實(shí)現(xiàn)起來(lái)方便一些,Java 只能走系統(tǒng)調(diào)用。

          在最近過(guò)去的性能挑戰(zhàn)賽中,由于限定了不能使用 JNI,使得選手們只能將方案止步于循環(huán)展開(kāi)方案,試想一下,如果允許走系統(tǒng)調(diào)用,加上比賽中字符串也基本是固定的長(zhǎng)度,完全可以采用 SIMD 的 trick 方案,String 轉(zhuǎn) Long 的速度會(huì)更快。

          polardb優(yōu)化點(diǎn)

          實(shí)際上,在之前 polarDB 的比賽中,普哥就給我介紹過(guò) bswap 的向量化方案,這也是為啥 Java 方案就是比 C++ 方案遜色的原因之一,C++ 在執(zhí)行一些 CPU 指令集以及系統(tǒng)調(diào)用上,比 Java 方便很多。

          如何看待這一系列的優(yōu)化呢?從 std::stringstream 的 86.23 到 sima trick 方案的 0.75,這個(gè)優(yōu)化的過(guò)程是令人興奮的,但我們也發(fā)現(xiàn),越往后,越是用到一些底層的優(yōu)化技巧,正如方案中的 trick 而言,適用性是有限的。也有一種聲音是在說(shuō):花費(fèi)這么大精力去優(yōu)化,為啥不去寫匯編呢?這又回到了“優(yōu)化是萬(wàn)惡之源”這個(gè)話題。在業(yè)務(wù)項(xiàng)目中,可能你不用過(guò)多關(guān)注 String 是如何轉(zhuǎn)換為 Long 和 Integer 的,可能 Integer.valueOf 和 Long.valueOf 就可以滿足你的訴求,但如果你是一個(gè)需要大數(shù)據(jù)解析系統(tǒng),String 轉(zhuǎn)換是系統(tǒng)的瓶頸之一,相信本文的方案會(huì)給你一定的啟發(fā)。

          另外對(duì)于 SIMD 這些方案,我想再多說(shuō)一句。其實(shí)一些性能挑戰(zhàn)賽進(jìn)行到最后,大家的整體方案其實(shí)都相差無(wú)幾,無(wú)非是參數(shù)差異,因?yàn)楸荣悎?chǎng)景通常不會(huì)太復(fù)雜,最后前幾名的差距,就是在一些非常小的細(xì)節(jié)上。正如 SIMA 提供的向量化運(yùn)算等優(yōu)化技巧,它就是可以幫助你比其他人快個(gè)幾百毫秒,甚至 1~2s。這時(shí)候你會(huì)感嘆,原來(lái)我跟大神的差距,就是在這些細(xì)節(jié)上。但反觀整個(gè)過(guò)程,似乎這些優(yōu)化并不能幫助程序設(shè)計(jì)競(jìng)賽發(fā)揮更大的能量,一個(gè)比賽如果只能依靠 CPU 優(yōu)化來(lái)實(shí)現(xiàn)區(qū)分度,我覺(jué)得一定不是成功的。所以,對(duì)于主辦方而言,禁用掉一些類庫(kù),其實(shí)有效的避免了內(nèi)卷,于參賽者而言,算是一種減負(fù)了。希望以后的比賽也都朝著讓選手花更多精力去優(yōu)化方案,而不是優(yōu)化通用的細(xì)節(jié)上。

          再回到 String 解析成 Long/Integer 的話題上。在實(shí)際使用時(shí),大家也不用避諱繼續(xù)使用 Integer.valueOf 或者 Long.valueOf,大多數(shù)情況下,這不是系統(tǒng)的瓶頸。而如果你恰好在某些場(chǎng)景下遇到了 String 轉(zhuǎn)換的瓶頸,希望本文能夠幫到你。

          - END -

          往期推薦

          OAuth2 服務(wù)器Keycloak中的Realm

          Java 17 將至,可能帶來(lái)哪些新特性呢?

          機(jī)械妖姬上門要源碼后續(xù)結(jié)果來(lái)了!

          重磅消息:Spring 6 和Spring Boot 3

          短信驗(yàn)證碼登錄流程思路及詳細(xì)步驟



          喜歡本文歡迎轉(zhuǎn)發(fā),關(guān)注我訂閱更多精彩

          關(guān)注我回復(fù)「加群」,加入Spring技術(shù)交流群

          瀏覽 51
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  91福利免费观看 | 天堂草原电视剧一念天堂草原电视剧v8给我发 | 无码人妻精品一二三在线99绯色 | 国产色视频在线看 | 午夜蕉视频 |