Skip to content

Commit

Permalink
[compiler] use perfect hashes for switches with small strings
Browse files Browse the repository at this point in the history
Previously, we compiled a string switch as follow:

	1. compute a tag (condition) string hash
	2. switch over precomputed hashes of cases
	3. inside a switch case, do `equals()` for a string

So, we compile a switch as a 2-level comparison: hash matching
plus `equals()` call on a string.

For shorter strings we can avoid the third step by using a
hash that will not result in collisions and identifies a string completely.

Since we're using uint64_t for switch hashes, we have 8 bits for information.
We can store up to 8 chars inside that without any losses.
Our hash function will be more or less `memcpy(&h, s, s_len)`.

Hash of a single char becomes a value in `0 <= x <= 255` range, which
is great for C++ switch density: it's likely to get that switch compiled
as a jump table (useful for lexers, etc).

Another common case is a shared prefix of all case values.

	case 'OPERATION_DELETE'
	case 'OPERATION_INSERT'
	case 'OPERATION_UPDATE'

All cases above have a length of 16 that is too much for our new hashing.
But if we remove the common `OPERATION_` prefix, strings fit into 6 bytes.

When we have such switch with common prefixes and varying suffixes that
fit into 8 bytes, we compare the prefix before hashing, if it does match,
variable cases tail is hashed (`DELETE`, `INSERT`, `UPDATE` from above).

Since optimized form doesn't require extra if statement inside every case,
the binary size gets smaller as well (but not dramatically).

In average, we compile 60% of string switches using the optimized scheme.
(~50% for short strings plus around ~10% for prefix form)

Benchmark results:

    name                                        old time/op  new time/op  delta
    SwitchOnlyDefault                           70.0ns ± 0%  29.0ns ± 0%  -58.57%
    Lexer                                        119ns ± 1%    78ns ± 2%  -35.04%
    LexerMiss                                   87.0ns ± 0%  77.0ns ± 0%  -11.49%
    LexerComplex                                 115ns ± 1%    93ns ± 2%  -19.43%
    LexerComplexMiss                            87.0ns ± 0%  74.6ns ± 1%  -14.25%
    Switch8oneChar                               158ns ± 0%    95ns ± 1%  -39.81%
    Switch8oneCharMiss                           108ns ± 1%   102ns ± 1%   -5.48%
    Switch8oneCharNoDefault                      164ns ± 1%    87ns ± 1%  -46.67%
    Switch8oneCharNoDefaultMiss                  117ns ± 0%    97ns ± 1%  -17.35%
    Switch6perfectHash                           258ns ± 1%   204ns ± 1%  -21.19%
    Switch6perfectHashMiss                       130ns ± 0%   119ns ± 1%   -8.21%
    Switch6perfectHashNoDefault                  271ns ± 1%   193ns ± 1%  -28.72%
    Switch6perfectHashNoDefaultMiss              138ns ± 1%   115ns ± 1%  -16.58%
    Switch12perfectHash                          278ns ± 0%   194ns ± 4%  -30.19%
    Switch12perfectHashMiss                      139ns ± 1%   109ns ± 1%  -21.62%
    Switch12perfectHashNoDefault                 275ns ± 1%   190ns ± 0%  -30.74%
    Switch12perfectHashNoDefaultMiss             142ns ± 1%   108ns ± 0%  -24.05%
    Switch6perfectHashWithPrefix                 286ns ± 0%   219ns ± 3%  -23.40%
    Switch6perfectHashWithPrefixMiss             190ns ± 1%   143ns ± 2%  -24.67%
    Switch6perfectHashWithPrefixNoDefault        293ns ± 0%   230ns ± 1%  -21.58%
    Switch6perfectHashWithPrefixNoDefaultMiss    194ns ± 1%   153ns ± 2%  -20.82%
    Switch12perfectHashWithPrefix                303ns ± 1%   225ns ± 2%  -25.69%
    Switch12perfectHashWithPrefixMiss            159ns ± 0%   113ns ± 0%  -28.93%
    Switch12perfectHashWithPrefixNoDefault       308ns ± 1%   224ns ± 1%  -27.18%
    Switch12perfectHashWithPrefixNoDefaultMiss   141ns ± 1%   117ns ± 2%  -17.14%
    [Geo mean]                                   171ns        130ns       -23.93%

Another change is that we now compile a default-only switch a little bit differently.
We don't compute a hash and discard auto-generated variables to avoid C++ compiler warnings.

Refs #418
Fixes #503
  • Loading branch information
quasilyte committed Apr 26, 2022
1 parent bf8e727 commit fbebcaa
Show file tree
Hide file tree
Showing 8 changed files with 1,473 additions and 13 deletions.
108 changes: 108 additions & 0 deletions common/algorithms/switch_hash-test.cpp
@@ -0,0 +1,108 @@
// Compiler for PHP (aka KPHP)
// Copyright (c) 2022 LLC «V Kontakte»
// Distributed under the GPL v3 License, see LICENSE.notice.txt

#include <gtest/gtest.h>
#include <unordered_set>

#include "common/algorithms/switch_hash.h"
#include "common/algorithms/contains.h"

TEST(test_switch_hash, zero_hashes) {
std::vector<std::string> strings = {
"this string is too long",
{"null\0", 5},
"",
};
for (const auto &s : strings) {
ASSERT_EQ(vk::case_hash8(s.data(), s.size()), 0);
}
}

TEST(test_switch_hash, collisions) {
// test that all hashes produced for these strings are unique
std::vector<std::string> strings = {
"foo",
"fo",
"f",
"oof",
"of",
"ofo",
"fooo",
"ooof",
"fff",
"ooo",
"a\xfa",
"a\xfb",
"b\xfa",
"b\xfb",
"a\x7f",
"b\x7f",
};
std::unordered_set<uint64_t> hashes;
for (const auto &s : strings) {
uint64_t h = vk::case_hash8(s.data(), s.size());
ASSERT_TRUE(!vk::contains(hashes, h));
hashes.insert(h);
}
}

TEST(test_switch_hash, single_char) {
std::vector<const char*> chars = {
"a",
"A",
"b",
"Z",
"4",
"$",
"@",
"\n",
"\xff",
"\xfa",
};
for (const auto &ch : chars) {
uint64_t have = vk::case_hash8(ch, 1);
uint64_t have2 = vk::case_hash8<1>(ch, 1);
uint64_t want = static_cast<unsigned char>(ch[0]);
ASSERT_EQ(have, want);
ASSERT_EQ(have2, want);
}

ASSERT_EQ(0, vk::case_hash8<1>("foo", 3));
ASSERT_EQ(0, vk::case_hash8<1>("x\0", 2));
ASSERT_EQ(0, vk::case_hash8<1>("\0", 1));
}

TEST(test_switch_hash, non_matching) {
// the compiler ensures that switch statement cases don't have null bytes,
// but the tag string (condition value) can contain null bytes nonetheless;
// our hash function ensures that these hashes won't match
std::vector<std::string> shouldnt_match = {
{"\0", 1},
{"\0\0\0", 3},
{"\0foo", 4},
{"f\0oo", 4},
{"fo\0o", 4},
{"fo\0", 3},
{"fo\0\0o", 5},
{"foo\0", 4},
{"foo\0\0", 5},
{"foo\0\0\0", 6},
{"f\0o\0o", 5},
{"\0\0foo", 5},
{"\0\0\0foo", 6},
"f00",
"fooo",
"foa",
"fo0",
"fao",
"foa",
"fo",
"f",
"",
};
uint64_t foo_hash = vk::case_hash8("foo", 3);
for (const auto &s : shouldnt_match) {
ASSERT_NE(foo_hash, vk::case_hash8(s.data(), s.size())) << "string size is " << s.size();
}
}
59 changes: 59 additions & 0 deletions common/algorithms/switch_hash.h
@@ -0,0 +1,59 @@
// Compiler for PHP (aka KPHP)
// Copyright (c) 2022 LLC «V Kontakte»
// Distributed under the GPL v3 License, see LICENSE.notice.txt

#pragma once

#include <cstdint>

namespace vk {

inline bool validate_case_hash8_string(const char *s, int64_t s_len) {
constexpr int short_string_max_len = 8;
if (s_len > short_string_max_len || s_len == 0) {
return false;
}
// not using std::any_of here to avoid extra dependencies in runtime string decl header
for (int i = 0; i < s_len; i++) {
if (s[i] == 0) {
return false;
}
}
return true;
}

// case_hash8 calculates a short string hash without collisions
// can be used by both compiler and runtime;
//
// for compiler usage, validate_case_hash8_string should be used
// to check that all switch cases produce valid hash
//
// note that this function returns 0 for cases that should be handled
// by the "default" clause, this is why empty strings are not a
// valid candidate for hashing by this function
template<int MaxLen = 8>
uint64_t case_hash8(const char *s, int64_t s_len) {
static_assert(MaxLen >= 2 && MaxLen <= 8);
uint64_t h = 0;
if (s_len <= MaxLen) {
// terminating null byte can screw the hash sum;
// cases never have a null byte, so we throw away
// this string without calculating its hash
if (s[s_len-1] == 0) {
return 0;
}
// since compiler sees a condition over a constant size MaxLen above,
// it compiles this memcpy as a series of mov instructions to copy
// the data without an actual call or loops
memcpy(&h, s, s_len);
}
return h;
}

// a special case for cases with len 1
template<>
inline uint64_t case_hash8<1>(const char *s, int64_t s_len) {
return s_len == 1 ? static_cast<unsigned char>(s[0]) : 0;
}

} // namespace vk
1 change: 1 addition & 0 deletions common/common-tests.cmake
Expand Up @@ -5,6 +5,7 @@ prepend(COMMON_TESTS_SOURCES ${COMMON_DIR}/
algorithms/projections-test.cpp
algorithms/simd-int-to-string-test.cpp
algorithms/string-algorithms-test.cpp
algorithms/switch_hash-test.cpp
allocators/freelist-test.cpp
allocators/lockfree-slab-test.cpp
crc32c-test.cpp
Expand Down

0 comments on commit fbebcaa

Please sign in to comment.