A Tale of Two Optimisations(articles.foletta.org) |
A Tale of Two Optimisations(articles.foletta.org) |
The encoding lookup table is an array of four chars. I'd be surprised if accessing such an array has a detrimental effect on any program.
I also wonder why the graphs say "Encode / Decode", as if you are combining the performance of the encoding function and the decoding. Have you considered separating them?
It would also help reproducibility if you include your compiler, versions, and flags. You mentioned that you've turned off all optimizations, but I wonder why.
"O0" would certainly produces a lot of jumps for the switch. But O2 is certainly going to eliminate those jumps. In fact, with O2, gcc11 seems to produce identical code for switch and lookup table.
unsigned char lookup2_encode(const unsigned char dibit) {
return ' \r\n\t' >> (dibit * 8);
}
I'd expect that to be faster just from having the lookup table in the code itself, and not having to use a different cache line from the data segment (as well as avoiding pointer indirection). unsigned char poly_encode_2(const unsigned char dibit) {
return (27 + x * (14 + x * (-18 + x * 7))) / 3;
}
Haven’t measured.There are many things I'd fiddle with in the original source. I noticed that since I first saw this post, someone even contributed an AVX2 implementation[2]. I stayed strictly in the standard C land:
This function converts a given character to the corresponding half-nibble (or dibit, don't really know what to call a pair of bits):
uint8_t
ws_to_half_nibble(uint8_t ws)
{
return ((ws >> 1) & 3) | (ws >> 4) | (ws >> 5);
}
Encoding a half-nibble to one of the four whitespace characters can be done using: uint8_t
half_nibble_to_ws(uint8_t b)
{
const uint8_t b0 = b & 1;
const uint8_t b1 = b & 2;
return '\t' + b0 + 2 * b1 + 18 * (b0 & (b1 >> 1));
}
I haven't actually had a chance to investigate any performance gains, but this answers the question of whether we can replace the lookup tables with simple functions with no branching.[1]: https://www.nu42.com/2021/10/another-optimization-tale.html
https://godbolt.org/z/Gox7nYfxP
So it is surprising that OP saw branch mispredictions. What compiler and flags were used?
https://godbolt.org/z/xcT3exenr
The code doesn’t look great because no inlining.
In reality, if you have many bytes in the buffer and calling these functions in a loop, compilers should inline the function, loading all these magic vectors outside the loop. This way it won’t be any memory loads, except to fetch the source data.
for (int i = 0; i < 256; ++i) {
const char c[4] = {
encode_lookup_tbl[i >> 0 & 3],
encode_lookup_tbl[i >> 2 & 3],
encode_lookup_tbl[i >> 4 & 3],
encode_lookup_tbl[i >> 6 & 3],
};
encode_lookup_tbl2[i] = *(uint32_t*)c;
}
for (x = 0; x < bytes; x++) {
((uint32_t*)ws_out)[x] = encode_lookup_tbl2[bytes_in[x]];
}
Note that the resulting encode_lookup_tbl2 depends on the endianness, but as long as we make the table on demand it can remain portable. Also `ws_out` might have to be aligned for the potability (unaligned memory access can be slow at best and trap at worst) but it is easy to fulfill when those functions are only used internally. In my measurement this alone made it ~3.5x faster.The next obvious approach is to use SIMD. Pretty much every modern CPU gives you 16-byte shuffle, which can be used to convert 4 bits at a time. (EDIT: This turned out to be not very correct, see below.) I tried to write a quick example but it takes quite a bit of time to write and debug [1] ;-) (I may update this post if I get a working version.)
Lastly, it might be possible to use mmap to eliminate the intermediate buffer at all. I think the actual performance gain with and without mmap can vary much and you need to profile it, but it's worth considering.
[1] I realized that there is no bytewise shift operator in x86 SIMD (_mm_srli_epi8). Oops!
---
Added later: So the following is my crude AVX2 implementation. It is 20% faster than the bytewise version and 10% faster than the original SSSE3 version. It is not that satisfactory though because it had to do a lot of unpacking to get bytes into correct positions (I'm not a great SIMD coder after all).
const __m256i MASK = _mm256_set1_epi8(0xf);
_Alignas(32) char c[32];
for (int i = 0; i < 32; ++i) c[i] = encode_lookup_tbl[i & 3];
const __m256i MAPLO = _mm256_load_si256((void*)c);
for (int i = 0; i < 32; ++i) c[i] = encode_lookup_tbl[i >> 2 & 3];
const __m256i MAPHI = _mm256_load_si256((void*)c);
for (x = 0; x < bytes; x += 32) {
// abcdefghijklmnop ABCDEFGHIJKLMNOP
__m256i block = _mm256_loadu_si256((void*)(bytes_in + x));
// ah bh ch dh ... mh nh oh ph
__m256i hi = _mm256_and_si256(_mm256_srli_epi16(block, 4), MASK);
// al bl cl dl ... ml nl ol pl
__m256i lo = _mm256_and_si256(block, MASK);
// MAPLO[al] ... MAPLO[pl]
// MAPHI[al] ... MAPLO[pl]
// MAPLO[ah] ... MAPHI[ph]
// MAPHI[ah] ... MAPHI[ph]
__m256i mapped1 = _mm256_shuffle_epi8(MAPLO, lo);
__m256i mapped2 = _mm256_shuffle_epi8(MAPHI, lo);
__m256i mapped3 = _mm256_shuffle_epi8(MAPLO, hi);
__m256i mapped4 = _mm256_shuffle_epi8(MAPHI, hi);
// MAPLO[al] MAPLO[ah] ... MAPLO[hl] MAPLO[hh]
// MAPLO[il] MAPLO[ih] ... MAPLO[pl] MAPLO[ph]
// MAPHI[al] MAPHI[ah] ... MAPHI[hl] MAPHI[hh]
// MAPHI[il] MAPHI[ih] ... MAPHI[pl] MAPHI[ph]
__m256i shuf1 = _mm256_unpacklo_epi8(mapped1, mapped3);
__m256i shuf2 = _mm256_unpackhi_epi8(mapped1, mapped3);
__m256i shuf3 = _mm256_unpacklo_epi8(mapped2, mapped4);
__m256i shuf4 = _mm256_unpackhi_epi8(mapped2, mapped4);
// MAPLO[al] MAPHI[al] MAPLO[ah] MAPHI[ah] ... MAPLO[dl] MAPHI[dl] MAPLO[dh] MAPHI[dh] etc.
// bytewise:
// aaaabbbbccccdddd AAAABBBBCCCCDDDD
// eeeeffffgggghhhh EEEEFFFFGGGGHHHH
// iiiijjjjkkkkllll IIIIJJJJKKKKLLLL
// mmmmnnnnoooopppp MMMMNNNNOOOOPPPP
__m256i out1 = _mm256_unpacklo_epi8(shuf1, shuf3);
__m256i out2 = _mm256_unpackhi_epi8(shuf1, shuf3);
__m256i out3 = _mm256_unpacklo_epi8(shuf2, shuf4);
__m256i out4 = _mm256_unpackhi_epi8(shuf2, shuf4);
_mm_store_si128((void*)(ws_out + x * 4), _mm256_extracti128_si256(out1, 0));
_mm_store_si128((void*)(ws_out + x * 4 + 16), _mm256_extracti128_si256(out2, 0));
_mm_store_si128((void*)(ws_out + x * 4 + 32), _mm256_extracti128_si256(out3, 0));
_mm_store_si128((void*)(ws_out + x * 4 + 48), _mm256_extracti128_si256(out4, 0));
_mm_store_si128((void*)(ws_out + x * 4 + 64), _mm256_extracti128_si256(out1, 1));
_mm_store_si128((void*)(ws_out + x * 4 + 80), _mm256_extracti128_si256(out2, 1));
_mm_store_si128((void*)(ws_out + x * 4 + 96), _mm256_extracti128_si256(out3, 1));
_mm_store_si128((void*)(ws_out + x * 4 + 112), _mm256_extracti128_si256(out4, 1));
} _mm256_store_si256((void*)(ws_out + x * 4), _mm256_permute2x128_si256(out1, out2, 0x20));
_mm256_store_si256((void*)(ws_out + x * 4 + 32), _mm256_permute2x128_si256(out3, out4, 0x20));
_mm256_store_si256((void*)(ws_out + x * 4 + 64), _mm256_permute2x128_si256(out1, out2, 0x31));
_mm256_store_si256((void*)(ws_out + x * 4 + 96), _mm256_permute2x128_si256(out3, out4, 0x31));
The relative time to encode 1 MB input (timed with 8,192 iterations): 5.51x original
1.00x bytewise (baseline)
0.83x SSSE3 (the original version I've posted)
0.74x AVX2 (parent)
0.60x AVX2 (updated)
Unfortunately my machine (i7-7700) can't run anything beyond AVX2.You machine has BMI2. It's not SIMD, but it handles 8 bytes at a time, and very suitable for packing and unpacking these bits in this case.