-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Use hybrid C / Pascal strings in the evaluator #14442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
dda5c2f to
15a9f93
Compare
|
CC @Radvendii |
15a9f93 to
6f82634
Compare
|
OK I am taking a look at the sanitzer failure. |
|
Would to see this through :) |
d5a1e45 to
f89ef40
Compare
91905f3 to
2f15843
Compare
it would be lovely to minimize this from the VM test! |
2f15843 to
8e7cd91
Compare
|
What sort of performance improvement can we expect from this? (Memory, speed?) |
I think it's hard to say. For tvix the big performance win was saving a word of cache footprint by converting rust fat pointers to thin pointers. For C++nix the value representation already uses only one word for the pointer (and another word for context iirc) so the win is less clear. Plausibly length gets cheaper (I have definitely worked in systems where strlen is really costly!) but ime length doesn't end up being that hot in Nix evaluations. On the other hand, tvix had to incur the cost of an extra pointer indirection to find string length, so maybe the delta in this case is more clearly beneficial, since c++nix already has to chase a pointer to look up the length of a string. As is always the case, we should benchmark. |
|
In the meeting (going on right now) we also talked about how parsers going through strings character by character were needlessly quadratic. This might not be in our test suite today, but @tomberek can easily add such an example. I would expect that since this dramatically includes that use-case, even if it is doesn't improve other use-cases (but also doesn't make them worse) the PR should overall be worth it. In short, the plan to me is:
So all paths lead to merging, but we do make sure we have evidence first. |
|
I am also getting the "store path contains illegal base-32 character" error locally, but it looks like a normal store path in this case, and it's complaining about the path separator "/" |
|
Invoking it a different way I get this, which distinctly does not look like a store path. |
|
I would really not be surprised if this issue is just UB - the original implementation was pretty messy, and I never got around to combing through it to find all the ways I was holding C++ wrong. |
|
🎉 All dependencies have been resolved ! |
| constexpr operator const StringData &() const & | ||
| { | ||
| return *std::bit_cast<const StringData *>(this); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is very much UB. We need a better approach for static strings. Doing it in a well-defined manner with FAMs with constexpr isn't going to be possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, static strings are by far the hardest part of this change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am surprised this is UB. I would think that casting between types that have compatible layouts is not UB.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(if bit_cast is the wrong type of cast, sure, but I thought there was some way to cast along these lines.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically you would be able to do a reinterpret_cast if flexible array members were a standard thing and they would form a common initial sequence. char m_data[] is by all means non-standard but is just widely supported as a language extension. It's been added in C99, which was after C++98. I'm pretty sure the official status of whether this is well-formed is "YOLO".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Honestly that might be the best bet. It's certainly in the realm of non-standard language extensions but that should work out ok in practice at least for clang and gcc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what might be our best bet? changing std::bit_cast to reinterpret_cast does not solve the problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I moved Static to another file for less bad recompilation hits, and I added some static casts that I hope make for a safer YOLO :).
| bool empty() const noexcept | ||
| { | ||
| return s->size_ == 0; | ||
| auto * p = &s->string_data(); | ||
| // Save a dereference in the sentinal value case | ||
| if (p == &""_sds) | ||
| return true; | ||
| return p->size() == 0; | ||
| } | ||
|
|
||
| [[gnu::always_inline]] | ||
| size_t size() const noexcept | ||
| { | ||
| return s->size_; | ||
| return s->string_data().size(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this help us avoid more dereferences?
| bool empty() const noexcept | |
| { | |
| return s->size_ == 0; | |
| auto * p = &s->string_data(); | |
| // Save a dereference in the sentinal value case | |
| if (p == &""_sds) | |
| return true; | |
| return p->size() == 0; | |
| } | |
| [[gnu::always_inline]] | |
| size_t size() const noexcept | |
| { | |
| return s->size_; | |
| return s->string_data().size(); | |
| } | |
| bool empty() const noexcept | |
| { | |
| return size() == 0; | |
| } | |
| [[gnu::always_inline]] | |
| size_t size() const noexcept | |
| { | |
| auto * p = &s->string_data(); | |
| // Save a dereference in the sentinal value case | |
| if (p == &""_sds) | |
| return 0; | |
| return p->size(); | |
| } |
| // When there's no need to write to the string, we can optimize away empty | ||
| // string allocations. | ||
| // This function handles makeImmutableString(std::string_view()) by returning | ||
| // the empty string. | ||
| static const char * makeImmutableString(std::string_view s) | ||
| StringData & StringData::make(std::string_view s) | ||
| { | ||
| const size_t size = s.size(); | ||
| if (size == 0) | ||
| return ""; | ||
| auto t = allocString(size + 1); | ||
| memcpy(t, s.data(), size); | ||
| t[size] = '\0'; | ||
| return t; | ||
| auto & res = alloc(s.size()); | ||
| memcpy(&res.m_data, s.data(), s.size()); | ||
| res.m_data[s.size()] = '\0'; | ||
| return res; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can still handle the empty string by returning ""_sds, right?
|
This is what I've found:
|
|
Oh! Could it be that boehm is confused because we're holding onto pointers but they don't point at the beginning of the allocation? So then it concludes that there's no reference to it and frees it? hmm... presumably boehm is smarter than that... |
8e7cd91 to
ea6ba08
Compare
|
Well well well, boehm is that stupid. I don't think I can push to the branch because I'm not on the nix team, but here's my diff that solves the problem for me commit 4364dd6d3170471966ec805073a027e8e38d0b92
Author: Taeer Bar-Yam <[email protected]>
Date: Tue Nov 4 00:25:09 2025 +0100
store StringData in context
diff --git a/src/libexpr/eval-cache.cc b/src/libexpr/eval-cache.cc
index de74d2143..5d8e031e4 100644
--- a/src/libexpr/eval-cache.cc
+++ b/src/libexpr/eval-cache.cc
@@ -136,17 +136,17 @@ struct AttrDb
});
}
- AttrId setString(AttrKey key, std::string_view s, const char ** context = nullptr)
+ AttrId setString(AttrKey key, std::string_view s, const StringData ** context = nullptr)
{
return doSQLite([&]() {
auto state(_state->lock());
if (context) {
std::string ctx;
- for (const char ** p = context; *p; ++p) {
+ for (const StringData ** p = context; *p; ++p) {
if (p != context)
ctx.push_back(' ');
- ctx.append(*p);
+ ctx.append((*p)->view());
}
state->insertAttributeWithContext.use()(key.first)(symbols[key.second])(AttrType::String) (s) (ctx)
.exec();
diff --git a/src/libexpr/eval.cc b/src/libexpr/eval.cc
index 392022e39..428f52d46 100644
--- a/src/libexpr/eval.cc
+++ b/src/libexpr/eval.cc
@@ -818,13 +818,13 @@ void Value::mkString(std::string_view s)
mkStringNoCopy(StringData::make(s));
}
-static const char ** encodeContext(const NixStringContext & context)
+static const StringData ** encodeContext(const NixStringContext & context)
{
if (!context.empty()) {
size_t n = 0;
- auto ctx = (const char **) allocBytes((context.size() + 1) * sizeof(char *));
+ auto ctx = (const StringData **) allocBytes((context.size() + 1) * sizeof(StringData *));
for (auto & i : context) {
- ctx[n++] = StringData::make({i.to_string()}).view().data();
+ ctx[n++] = &StringData::make({i.to_string()});
}
ctx[n] = nullptr;
return ctx;
@@ -2288,8 +2288,8 @@ std::string_view EvalState::forceString(Value & v, const PosIdx pos, std::string
void copyContext(const Value & v, NixStringContext & context, const ExperimentalFeatureSettings & xpSettings)
{
if (v.context())
- for (const char ** p = v.context(); *p; ++p)
- context.insert(NixStringContextElem::parse(*p, xpSettings));
+ for (const StringData ** p = v.context(); *p; ++p)
+ context.insert(NixStringContextElem::parse((*p)->view().data(), xpSettings));
}
std::string_view EvalState::forceString(
diff --git a/src/libexpr/include/nix/expr/value.hh b/src/libexpr/include/nix/expr/value.hh
index aa275eda2..3dee1be02 100644
--- a/src/libexpr/include/nix/expr/value.hh
+++ b/src/libexpr/include/nix/expr/value.hh
@@ -264,7 +264,7 @@ public:
constexpr operator const StringData &() const &
{
- return *std::bit_cast<const StringData *>(this);
+ return *reinterpret_cast<const StringData *>(this);
}
constexpr operator std::string_view() const &
@@ -335,7 +335,7 @@ struct ValueBase
struct StringWithContext
{
const StringData * str;
- const char ** context; // must be in sorted order
+ const StringData ** context; // must be in sorted order
};
struct Path
@@ -1106,7 +1106,7 @@ public:
setStorage(b);
}
- void mkStringNoCopy(const StringData & s, const char ** context = 0) noexcept
+ void mkStringNoCopy(const StringData & s, const StringData ** context = nullptr) noexcept
{
setStorage(StringWithContext{.str = &s, .context = context});
}
@@ -1234,7 +1234,7 @@ public:
return string_data().view();
}
- const char ** context() const noexcept
+ const StringData ** context() const noexcept
{
return getStorage<StringWithContext>().context;
}
|
This will make it easier to change this type in the future. See new TODO on naming. The thing we already so-named is a builder type for string contexts, not the on-heap type. Co-Authored-By: Taeer Bar-Yam <[email protected]
Replace the null-terminated C-style strings in Value with hybrid C / Pascal strings, where the length is stored in the allocation before the data, and there is still a null byte at the end for the sake of C interopt. Co-Authored-By: Taeer Bar-Yam <[email protected]>
ea6ba08 to
874640c
Compare
|
Applied the fix, and factored out a new prep PR of #14470 to keep this smaller. |
|
boehm delenda est |
Well, it's not suprising consdering that we have interior pointer detection only for the first 8 bytes and only on 64 bit systems to make that work with low-bit tagging. |
This might be my next project.
I was wondering how that worked! |
Motivation
This is a draft PR to replace the null-terminated C-style strings in Value with pascal strings, where the length is stored in the allocation before the data. This should be considered basically an experiment right now - this ended up being a big optimization for tvix, but it's unclear how much of a win it'd be for cppnix
Context
Depends on #14444
Depends on #14470