Sentinels Can Be Faster
lemire.me
lemire.me
If you’re skeptical, I really do encourage you to watch, he’s not sloppy.
This was back when we could assume 8-bit encodings(iso 8859-1 was the one we used). Simpler times!
The problem is that when an article gets shared on HN, people hold it to unreasonable standards of clarity and correctness... the kind of standards that are usually only achieved with careful editing. It's appropriate to level that kind of criticism at a textbook or a Wikipedia article, but the feedback is unwelcome for simple blog posts like this.
There's also a weird current of pedantry here, like "I understood the article, but someone else might misunderstand, and so the article should be clearer."
I only said it would be better if it were clearer. I'm not arguing that it's unfit for HN or any other venue. You're reading much too far into this.
No, the system call interface of the operating system expects NUL-terminated strings as parameters all over the place, especially the filesystem APIs.
EDIT: Okay, this is at -1, maybe I shouldn't be slightly obtuse. These questions are, in my mind, two sides of the same coin. The OS expects null terminated strings, not for some inherent reason, but because C and UNIX grew up together. There's no inherent reason you can't have an OS that does not use sentinels to demarcate strings.
I'm actually a little curious how what these low-level APIs look like and how "modern" languages handle efficiently converting to null-terminated strings. Do they just copy everything while C/C++/etc would pass a straight reference? Also, what do the underlying syscall APIs look like?
And yeah, in Rust we have a separate CString type that's null terminated, so you may need a copy to move between the two worlds. It just depends, if you're only reading, for example, then you can create a pointer + len where the length doesn't include the 0, and that's effectively zero cost.
You got me curious about my days as a kid putting "pascal" in front of Mac System 7 stuff; I believe that it only accepted Pascal strings. I also vaguely remember people doing funny stuff like null terminating pascal strings anyway and then putting the length before the actual pointer so that you get both in one...
https://quick-bench.com/q/314Z81FskTlcDqMCUHFVhWmDz8Q
Update 1: After moving some constants around, I get the 40% result:
https://quick-bench.com/q/lPrpQTAyDQuOoKS9MBWCTBXk1TE
No idea why it made such a big difference to the benchmark.
Update 2: If the test order is reversed, the result goes back to being only slightly faster for the sentinel version:
I was going to just point out that 40% percent difference would mean that the version without the sentinel can be improved... was going to check if there is something that is preventing the branch prediction from actually taking care of that performance drop - memory is only being read and nothing should be invalidated...
If your language has a garbage collector, it may mean tiny substrings keep huge strings alive. Java moved away from using slices (https://dzone.com/articles/changes-stringsubstring-java-7), I think for that reason.
If your language isn’t garbage collected, it can cause problems if the substring must outlive the string it was extracted from. That means that, for some functions, you’ll have to decide whether to return the slice or make the copy.
(In Rust, that means the slice has a different ‘type’ (not sure what they call it), that of a string whose lifetime is bound to the original string, and the compiler will check that you won’t make the mistake of having the slice outlive it’s source)
Also, using slices doesn’t come for free; it makes all String objects larger because, apart from the reference to the underlying character array, an offset and length must be kept around.
(And yeah, you're right about Rust too; string slices are a different type than owned strings.)
BTW. If you really want speed, SIMD will obliterate any non-SIMD solution, and my bet would be that most of the time SIMD will be easier to implement with length prefix because it will not have to handle a special case of null-termination. Someone correct me if I'm wrong.
Trailing zero is a a typical case of a short-signed hack. More naive, simpler, natural solution (length prefix) actually works better, especially long term, where other things change (hardware architecture, performance and resource constraint, use cases).
It allows making substrings without copying (and there's no costly substring gotcha you get in GC languages, because the borrow checker will loudly remind you when you try to keep the substring longer than the original string).
In string-processing algorithms the length usually stays in a register, so (unlike Pascal string approach) it doesn't contribute to on-heap cost of the string. Knowing length ahead of time allows having SIMD-optimized loops.
Sometimes you find that in some complicated system, there's a big chunk of overhead which you spend just parsing things. So, carefully, thinking about the consequences, you can decide to use a sentinel for end of input rather than length.
A larger, practical example: a C++ compiler spends a shocking amount of time just doing lexical analysis on its inputs. One thing that Clang does to speed it up is to get rid of the length check, just like suggested in the article.
https://github.com/llvm-mirror/clang/blob/master/lib/Lex/Lex...
/// LexTokenInternal - This implements a simple C family lexer. It is an
/// extremely performance critical piece of code. This assumes that the buffer
/// has a null character at the end of the file. [...]
bool Lexer::LexTokenInternal(Token &Result, bool TokAtPhysicalStartOfLine) {
[...]
if ((*CurPtr == ' ') || (*CurPtr == '\t')) {
++CurPtr;
while ((*CurPtr == ' ') || (*CurPtr == '\t'))
++CurPtr;
[...]
}
[...]
}
There are a number of places in the world where you might find that text parsing is performance-critical. Note that in the rest of the code base, Clang uses really quite standard and boring std::string to represent strings. You don't have to use sentinels everywhere, but it's a trick to keep up your sleeve for the cases where it makes a difference.And of course, you keep the parts of your code with sentinels self-contained because it can be hard to figure out whether the code is correct without a close reading.
You often cannot know whether that element is writable, and if it is, that will cause problems when running concurrent searches (even for the same key)
> Zero spaces is a number of leading spaces. If you feed his efficient solution a string without a leading space, it'll read past the end of the string and into undefined memory until the program either crashes or reads some arbitrary space character (0x20) from some other string. If it contains spaces but doesn't start with one, you'll think it has more leading spaces than it actually does.
I don't know what code you are referring to, but I'm looking at this code:
const char * skip_leading_spaces(const char * start) {
while(is_space(*start)) {
start++;
}
return start;
}
And this correctly handles the case with zero leading spaces. It correctly handles empty strings.Recall that is_space('\0') is false (or at least isspace('\0') is false). Also recall that it is valid to read the \0 byte at the end of a string.
I find it a bit unusual that you are capitalizing NULL. My interpretation is that you are talking about the byte '\0', which is also called the null byte, and not the NULL pointer.
The functions do assume the parameters are non-null pointers to NUL terminated strings, but that kind of assumption is pretty standard in C APIs. So other than that, I don't see any missing NULL check nor do I see the bug you refer to that occurs when there are no leading spaces.