Skip to content
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

Neater hashing interface #4524

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open

Neater hashing interface #4524

wants to merge 22 commits into from

Conversation

widlarizer
Copy link
Collaborator

@widlarizer widlarizer commented Aug 6, 2024

Please read docs/source/yosys_internals/hashing.rst for what the interface is now.

We want to be able to plug-and-play hash functions to improve hashlib structure collision rates and hashing speed. Currently, in some ways, hashes are incorrectly handled: for deep structure hashing, each substructure constructs a hash from scratch, and these hashes are then combined with addition, XOR, sometimes xorshifted to make this less iffy, but overall, this is risky, as it may degrade various hash functions to varying degrees. It seems to me that the correct combination of hashes is to have a hash state that is mutated with each datum hashed in sequence. That's what this PR does.

  • check performance impact since this PR isn't NFC as it changes how structures are hashed
  • clean up things left over from experiments with inheriting from Hashable
  • fix pyosys
  • finish the plugin compatibility part of the doc

@widlarizer
Copy link
Collaborator Author

Cool, I got hit in the face with a downright gcc bug

@widlarizer
Copy link
Collaborator Author

With ibex and jpeg synthesized with ORFS, I'm seeing a 1% performance regression with this PR. This is probably because we're actually using the seed function more directly, with less xorshifting involved. I wonder if a quick swap of the hashing function would change the result. However, I'm also seeing a 1% memory usage improvement with jpeg, which is pretty interesting

@widlarizer
Copy link
Collaborator Author

Passes like extract_fa and opt_dff take 5-10% longer with this change. This is definitely a problem

@povik
Copy link
Member

povik commented Oct 29, 2024

Leaving a note so I don't forget it: We should provide a way for plugins to be compatible with both pre and post this change. I am thinking a define to advertise the new API.

Copy link
Member

@jix jix left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Despite the many comments I left, I really like the new hashing interface and think it's a big improvement over the previous one.

I found a bunch of nits, made a few suggestions on how to potentially improve some of the hash_eat implementations, am slightly confused by the current choice of state update in the used hasher implementation and found one instance of unconditionally recomputing an already cached hash.

edit: Oh, and to clarify, I'm fine with deferring my suggestions that would need some more benchmarking to a follow up PR and merge this once everything that's a clear issue right now or a clear benefit to change is addressed.

if (hash_ != 0) return hash_;

void updhash() const {
DriveSpec *that = (DriveSpec*)this;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's that used for? also why was the check whether the hash is already known removed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that is a dirty const laundering trick yosys had been using before as well. The idea is something like "const methods are allowed to modify members, as long as the data represented by the record is kept equivalent, typically in the operator== meaning of equivalency". I think I've previously successfully replaced this approach by declaring things mutable and should apply it here as well

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, I somehow missed the const, considering constness this makes sense, but I'd also prefer replacing it with mutable

bool operator==(DriveSpec const &other) const {
if (size() != other.size() || hash() != other.hash())
updhash();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these updhash calls are not guarded by hash_ != 0 checks and the check within that function (or rather the function that previously contained the same logic) was removed, so this will unconditionally recompute the hash with every equality comparison.


inline Hasher DriveSpec::hash_eat(Hasher h) const
{
if (hash_ == 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the calls above updhash call is guarded, but I think it would be simpler to keep the check in updhash itself.

public:
void hash32(uint32_t i) {
state = djb2_xor(i, state);
state = mkhash_xorshift(fudge ^ state);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was expecting to only see a djb hash update in here, but it is followed up by a xorshift. What's the reason for doing both? Without benchmarking I would be guessing that performing a djb hash update and a xorshift update is slower than either alone without necessarily having better overall runtime behavior than the better behaved one of both.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In an intermediate state, I found it necessary for lowering collisions and it doesn't cost so much as to cause a measurable regression against main on the ASIC designs I used to check. Without it, DJB2 doesn't have an avalanche property. Also see this observation basically about patterns like hash = mkhash(xorshift(mkhash(a, b)), c)

return v;
static inline Hasher hash_eat(const T &a, Hasher h) {
if constexpr (std::is_same_v<T, bool>) {
h.hash32(a ? 1 : 0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would find a cast to an int type cleaner for converting a bool to a 0/1 int here. I'd expect an optimizing compiler to generate the same code, but since I find the two variants equally easy to read I'd prefer the one that doesn't require an optimization to result in the code we want.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, good to dispell another remnant of my C paranoia caused by embedded lore - true bools of value other than zero don't exist with C++ bool or C stdbool.h bool

#undef YOSYS_NO_IDS_REFCNT

// the global id string cache
struct RTLIL::IdString
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly moving this turns the diff into a mess. Are there any functional changes to this besides updating the hashing implementation?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that rtlil.h both creates per-type partial specializations for hash_top_ops defined in hashlib.h as well as uses data structures defined in hashlib.h. No functional changes other than hash_top_ops and hash_eat.

inline Hasher hash_eat(Hasher h) const {
// TODO hash size
for (auto b : *this)
h.eat(b);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assuming my suggestion of hashing strings in chunks is implemented, the vector of State enums could also be hashed as if it were a string. If this is using a string representation the same is true. I'm assuming this would cause issues when we have two equal consts, one represented as states one as string. I still have to take a closer look at how the new string representation for constants is implemented to say more on this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a string represented as string vs as bit vector to have equal hashes, so we have to go bit by bit. To hash a contiguous chunk, for string represented Const, we'd need to first construct a new bit vector to run it on that. So it doesn't sound like it pays off

size_t get_hash() const {
if (!hash_) hash();
return hash_;
log_assert(false && "deprecated");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we log_assert(false) anyway, i.e. already remove support, why not outright remove it instead of deprecating it?

return hash_ops<uintptr_t>::hash_eat((uintptr_t) a, h);
} else if constexpr (std::is_same_v<T, std::string>) {
for (auto c : a)
h.hash32(c);
Copy link
Member

@jix jix Nov 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what I wrote for the c string hash_eat below above also applies here, but I initially missed that std::string is handled here

@@ -1181,7 +1111,8 @@ struct DriverMap
bool operator==(const DriveBitId &other) const { return id == other.id; }
bool operator!=(const DriveBitId &other) const { return id != other.id; }
bool operator<(const DriveBitId &other) const { return id < other.id; }
unsigned int hash() const { return id; }
// unsigned int hash() const { return id; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: commented out code

Let's first take a look at the external interface on a simplified level.
Generally, to get the hash for ``T obj``, you would call the utility function
``run_hash<T>(const T& obj)``, corresponding to ``hash_top_ops<T>::hash(obj)``,
the default implementation of which is ``hash_ops<T>::hash_eat(Hasher(), obj)``.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I much prefer hash_into for the name of the method here, while .eat stays being the method on the Hasher


inline unsigned int T::hash() const {
Hasher h;
return (unsigned int)hash_eat(h).yield();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand, if I compile my plugin against v0.47 or earlier I won't have Hasher

current interface and redirecting the legacy one:

``void Hasher::eat(const T& t)`` hashes ``t`` into its internal state by also
redirecting to ``hash_ops<T>``
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this done for legacy reasons? I am not clear on why this paragraph is below "Porting plugins from the legacy interface"

@@ -0,0 +1,153 @@
Hashing and associative data structures in Yosys
------------------------------------------------
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving a note that I read this file


DJB2 lacks these properties. Instead, since Yosys hashes large numbers of data
structures composed of incrementing integer IDs, Yosys abuses the predictability
of DJB2 to get lower hash collisions, with regular nature of the hashes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it to get lower hash collisions or to get better locality?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hash collisions

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this come from observations, or something Claire mentioned was intention? I know some of the primitives were used in a way to get better locality

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from my observations when counting hash collisions in hashlib per std::source_location and clear correlation with extra runtime overhead in some opt and extract_fa passes where the collisions were happening

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants