Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions sandbox/ChibiRuby.Benchmark/ruby/bm_call_hashkey.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class K
def hash
42
end
def eql?(o)
true
end
end
k = K.new
h = { k => 1 }
i = 0
n = 1_000_000
x = 0
while i < n
x = h[k]
i += 1
end
x
9 changes: 9 additions & 0 deletions sandbox/ChibiRuby.Benchmark/ruby/bm_call_hashstr.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
h = { "foo" => 1, "bar" => 2 }
i = 0
n = 1_000_000
x = 0
while i < n
x = h["foo"]
i += 1
end
x
9 changes: 9 additions & 0 deletions sandbox/ChibiRuby.Benchmark/ruby/bm_call_hashsym.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
h = { foo: 1 }
i = 0
n = 1_000_000
x = 0
while i < n
x = h[:foo]
i += 1
end
x
4 changes: 3 additions & 1 deletion sig/array.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ class Array[Elem] < Object
# [1, 2] == [1, 2, 3] # => false
def ==: (untyped) -> bool

def eql?: (untyped) -> bool

# Returns true when both arrays have the same length and corresponding elements are eql?.
#
# [1, 2].eql?([1, 2]) # => true
# [1, 2].eql?([1.0, 2.0]) # => false
def eql?: (untyped) -> bool
def hash: () -> Integer

# Appends the given element(s) to self and returns self.
#
Expand Down
12 changes: 12 additions & 0 deletions sig/hash.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ class Hash[K, V] < Object
# h.rehash # => h
def rehash: () -> self

# Switches the hash to identity key semantics: keys are compared by object identity instead of hash/eql?, so mutating a key object does not invalidate it.
#
# h = {}.compare_by_identity
# a = [0]
# h[a] = 42
# a[0] = 1
# h[a] # => 42
def compare_by_identity: () -> self

# Returns whether the hash compares keys by identity.
def compare_by_identity?: () -> bool

# Internal helper used by Hash#delete to remove and return the value for a key.
def __delete: (K) -> V?

Expand Down
6 changes: 1 addition & 5 deletions sig/kernel.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,7 @@ module Kernel
# 1.frozen? # => true
def frozen?: () -> bool

# Returns a hash code for self, suitable for use as a Hash key.
#
# 1.hash # => Integer
# "hi".hash # => Integer
def hash: () -> Integer
def hash: (*untyped) -> untyped

# Returns true if the class of self is exactly klass (not a subclass).
#
Expand Down
5 changes: 4 additions & 1 deletion src/ChibiRuby/MRubyState.Init.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ void InitKernel()
DefineMethod(KernelModule, Names.QNil, MRubyMethod.False);
DefineMethod(KernelModule, Intern("freeze"u8), KernelMembers.Freeze);
DefineMethod(KernelModule, Intern("frozen?"u8), KernelMembers.Frozen);
DefineMethod(KernelModule, Names.Hash, KernelMembers.Hash);
DefineMethod(KernelModule, Names.Hash, KernelMembers.HashFunc);
DefineMethod(KernelModule, Intern("instance_of?"u8), KernelMembers.InstanceOf);
DefineMethod(KernelModule, Names.QIsA, KernelMembers.KindOf);
DefineMethod(KernelModule, Names.QKindOf, KernelMembers.KindOf);
Expand Down Expand Up @@ -615,6 +615,7 @@ void InitArray()

DefineMethod(ArrayClass, Names.OpEq, ArrayMembers.OpEq);
DefineMethod(ArrayClass, Names.QEql, ArrayMembers.Eql);
DefineMethod(ArrayClass, Names.Hash, ArrayMembers.Hash);
DefineMethod(ArrayClass, Names.OpLShift, ArrayMembers.Push);
DefineMethod(ArrayClass, Names.OpAdd, ArrayMembers.OpAdd);
DefineMethod(ArrayClass, Names.OpAref, ArrayMembers.OpAref);
Expand Down Expand Up @@ -685,6 +686,8 @@ void InitHash()
DefineMethod(HashClass, Intern("assoc"u8), HashMembers.Assoc);
DefineMethod(HashClass, Intern("rassoc"u8), HashMembers.RAssoc);
DefineMethod(HashClass, Intern("rehash"u8), HashMembers.Rehash);
DefineMethod(HashClass, Intern("compare_by_identity"u8), HashMembers.CompareByIdentity);
DefineMethod(HashClass, Intern("compare_by_identity?"u8), HashMembers.QCompareByIdentity);
DefineMethod(HashClass, Intern("__delete"u8), HashMembers.InternalDelete);
DefineMethod(HashClass, Intern("__merge"u8), HashMembers.InternalMerge);
DefineMethod(HashClass, Intern("__except"u8), HashMembers.InternalExcept);
Expand Down
73 changes: 70 additions & 3 deletions src/ChibiRuby/MRubyValueEqualityComparer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using ChibiRuby.StdLib;

namespace ChibiRuby;

Expand All @@ -15,15 +18,79 @@ public int GetHashCode(MRubyValue value)
}
}

public sealed class MRubyValueHashKeyEqualityComparer(MRubyState state) : IEqualityComparer<MRubyValue>
/// <summary>
/// Key semantics for Hash, mirroring mruby's ht_hash_value / ht_hash_equal:
/// immediates and strings are handled natively (by value / by content), while other
/// objects defer to the Ruby-visible `hash` / `eql?` methods so user overrides are
/// honored. The default `Kernel#hash` (identity) is detected via the method cache and
/// short-circuited to avoid a full Send per operation on identity-keyed hashes.
///
/// The same class also implements Hash#compare_by_identity (via <see cref="ByIdentity"/>)
/// rather than a separate comparer type, so Dictionary probe sites stay monomorphic for
/// the JIT's guarded devirtualization.
/// </summary>
public sealed class MRubyValueHashKeyEqualityComparer(MRubyState state, bool byIdentity = false)
: IEqualityComparer<MRubyValue>
{
/// <summary>
/// Identity key semantics (Hash#compare_by_identity): objects compare by reference,
/// immediates by value; user-defined hash/eql? are ignored. Note strings are NOT
/// special-cased — two content-equal string instances are distinct keys, as in CRuby.
/// </summary>
public bool ByIdentity => byIdentity;

// The hot entry points stay tiny so the JIT's guarded devirtualization can inline
// them into Dictionary probes; everything type-dispatched lives in NoInlining tails.

public bool Equals(MRubyValue a, MRubyValue b)
{
return a == b || state.Send(a, Names.QEql, b).Truthy;
// Identity / immediate value equality. Remaining immediates
// (Integer/Float/Symbol/nil/true/false) are fully decided here.
if (a == b) return true;
return !byIdentity && a.Object is not null && EqualsSlow(a, b);
}

public int GetHashCode(MRubyValue value)
{
return value.GetHashCode();
// Immediates hash by value.
if (value.Object is null) return value.GetHashCode();
return byIdentity
? RuntimeHelpers.GetHashCode(value.Object)
: GetHashCodeSlow(value);
}

[MethodImpl(MethodImplOptions.NoInlining)]
int GetHashCodeSlow(MRubyValue value)
{
// Strings hash by content (RString overrides GetHashCode).
if (value.Object is RString)
{
return value.GetHashCode();
}

// Honor a user-defined `hash` override. When the resolved method is the default
// Kernel#hash (identity), skip the dispatch: bucketing by the CLR identity hash
// is consistent with the identity-based `eql?` default.
if (state.TryFindMethod(state.ClassOf(value), Names.Hash, out var method, out _) &&
method == KernelMembers.HashFunc)
{
return value.GetHashCode();
}

var hashValue = state.Send(value, Names.Hash);
return hashValue.IsInteger
? hashValue.IntegerValue.GetHashCode()
: hashValue.GetHashCode();
}
[MethodImpl(MethodImplOptions.NoInlining)]
bool EqualsSlow(MRubyValue a, MRubyValue b)
{
// Strings compare by content regardless of class, as in mruby's ht_hash_equal.
if (a.Object is RString sa)
{
return b.Object is RString sb && sa.AsSpan().SequenceEqual(sb.AsSpan());
}
return state.Send(a, Names.QEql, b).Truthy;
}

}
63 changes: 55 additions & 8 deletions src/ChibiRuby/RHash.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,29 @@ public sealed class RHash : RObject, IEnumerable<KeyValuePair<MRubyValue, MRubyV

readonly List<MRubyValue> keys;
readonly List<MRubyValue> values;
readonly Dictionary<MRubyValue, int> indexTable;
Dictionary<MRubyValue, int> indexTable;

readonly IEqualityComparer<MRubyValue> keyComparer;
IEqualityComparer<MRubyValue> keyComparer;
readonly IEqualityComparer<MRubyValue> valueComparer;

public bool ComparedByIdentity => keyComparer is MRubyValueHashKeyEqualityComparer { ByIdentity: true };

/// <summary>
/// Switch key semantics to identity (Hash#compare_by_identity): objects compare by
/// reference, immediates by value, ignoring user-defined hash/eql?. Existing entries
/// are re-bucketed; identity is strictly finer than eql?-equality so no entries merge.
/// </summary>
internal void CompareByIdentity(MRubyValueHashKeyEqualityComparer identityComparer)
{
if (ComparedByIdentity) return;
keyComparer = identityComparer;
indexTable = new Dictionary<MRubyValue, int>(keys.Count, keyComparer);
for (var i = 0; i < keys.Count; i++)
{
indexTable[keys[i]] = i;
}
}

internal RHash(
int capacity,
IEqualityComparer<MRubyValue> keyComparer,
Expand Down Expand Up @@ -65,7 +83,7 @@ public MRubyValue this[MRubyValue key]
}
set
{
if (indexTable.TryGetValue(key, out var index))
if (TryGetIndexGuarded(key, out var index))
{
keys[index] = key;
values[index] = value;
Expand Down Expand Up @@ -105,7 +123,7 @@ public void Add(MRubyValue key, MRubyValue value)
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ContainsKey(MRubyValue key) => indexTable.TryGetValue(key, out var i);
public bool ContainsKey(MRubyValue key) => TryGetIndexGuarded(key, out _);

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ContainsValue(MRubyValue value)
Expand Down Expand Up @@ -215,7 +233,7 @@ public void Rehash()
for (var readPos = 0; readPos < keys.Count; readPos++)
{
var key = keys[readPos];
if (indexTable.TryGetValue(key, out var existingIndex))
if (TryGetIndexGuarded(key, out var existingIndex))
{
values[existingIndex] = values[readPos];
}
Expand All @@ -231,8 +249,12 @@ public void Rehash()
}
}

keys.RemoveRange(writePos, keys.Count - writePos);
values.RemoveRange(writePos, values.Count - writePos);
// A user-defined #hash may have mutated this hash (e.g. cleared it) during the
// probes above; clamp so the trim never underflows.
var keyTrimStart = Math.Min(writePos, keys.Count);
var valueTrimStart = Math.Min(writePos, values.Count);
keys.RemoveRange(keyTrimStart, keys.Count - keyTrimStart);
values.RemoveRange(valueTrimStart, values.Count - valueTrimStart);
}

public struct Enumerator(RHash source) : IEnumerator<KeyValuePair<MRubyValue, MRubyValue>>
Expand Down Expand Up @@ -268,14 +290,39 @@ public void Dispose()
[MethodImpl(MethodImplOptions.AggressiveInlining)]
bool TryGetIndexOfKey(MRubyValue key, out int index)
{
if (indexTable.TryGetValue(key, out index))
if (TryGetIndexGuarded(key, out index))
{
return true;
}
index = -1;
return false;
}

/// <summary>
/// indexTable probe that survives a user-defined <c>#hash</c>/<c>#eql?</c> mutating
/// this hash mid-probe (mruby leaves the result unspecified but must not crash).
/// The Dictionary itself stays consistent after such a mutation — only the in-flight
/// probe's guard throws — so a single retry against the new state is sound.
/// </summary>
/// <summary>
/// indexTable probe that survives a user-defined <c>#hash</c>/<c>#eql?</c> mutating
/// this hash mid-probe (mruby raises "hash modified" there; the result is unspecified
/// but must not crash). The Dictionary itself stays consistent after such a mutation —
/// only the in-flight probe's guard throws — so a single retry against the new state
/// is sound.
/// </summary>
bool TryGetIndexGuarded(MRubyValue key, out int index)
{
try
{
return indexTable.TryGetValue(key, out index);
}
catch (InvalidOperationException)
{
return indexTable.TryGetValue(key, out index);
}
}

public Enumerator GetEnumerator() => new(this);

IEnumerator<KeyValuePair<MRubyValue, MRubyValue>> IEnumerable<KeyValuePair<MRubyValue, MRubyValue>>.GetEnumerator() =>
Expand Down
39 changes: 39 additions & 0 deletions src/ChibiRuby/StdLib/ArrayMembers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,45 @@ public static MRubyValue OpEq(MRubyState state, MRubyValue self)
/// [1, 2].eql?([1.0, 2.0]) # => false
/// </code>
/// </example>
/// <summary>
/// Returns a content-based hash code; equal (<c>eql?</c>) arrays have equal hashes.
/// </summary>
/// <example>
/// <code>
/// [1, 2].hash == [1, 2].hash # => true
/// </code>
/// </example>
[RubyDef("() -> Integer")]
public static MRubyValue Hash(MRubyState state, MRubyValue self)
{
// Same algorithm as Enumerable#hash in lib.rb (12347 seed + __update_hash),
// implemented natively because array keys make Hash probe this per operation.
var span = self.As<RArray>().AsSpan();
var h = 12347;
for (var i = 0; i < span.Length; i++)
{
var e = span[i];
int hv;
if (e.IsInteger)
{
// Matches IntegerMembers.Hash
var n = e.IntegerValue;
hv = unchecked((int)RString.GetHashCode(
System.Runtime.InteropServices.MemoryMarshal.CreateSpan(
ref System.Runtime.CompilerServices.Unsafe.As<long, byte>(ref n), sizeof(long))));
}
else
{
var hashValue = state.Send(e, Names.Hash);
hv = hashValue.IsInteger
? unchecked((int)hashValue.IntegerValue)
: hashValue.GetHashCode();
}
h ^= hv << (i % 16);
}
return new MRubyValue(h);
}

[RubyDef("(untyped) -> bool")]
public static MRubyValue Eql(MRubyState state, MRubyValue self)
{
Expand Down
29 changes: 29 additions & 0 deletions src/ChibiRuby/StdLib/HashMembers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,35 @@ public static MRubyValue Rehash(MRubyState state, MRubyValue self)
return self;
}

/// <summary>
/// Switches the hash to identity key semantics: keys are compared by object identity
/// instead of <c>hash</c>/<c>eql?</c>, so mutating a key object does not invalidate it.
/// </summary>
/// <example>
/// <code>
/// h = {}.compare_by_identity
/// a = [0]
/// h[a] = 42
/// a[0] = 1
/// h[a] # => 42
/// </code>
/// </example>
[RubyDef("() -> self")]
public static MRubyValue CompareByIdentity(MRubyState state, MRubyValue self)
{
var h = self.As<RHash>();
state.EnsureNotFrozen(h);
h.CompareByIdentity(new MRubyValueHashKeyEqualityComparer(state, byIdentity: true));
return self;
}

/// <summary>Returns whether the hash compares keys by identity.</summary>
[RubyDef("() -> bool")]
public static MRubyValue QCompareByIdentity(MRubyState state, MRubyValue self)
{
return self.As<RHash>().ComparedByIdentity;
}

/// <summary>Internal helper used by <c>Hash#delete</c> to remove and return the value for a key.</summary>
[RubyDef("(K) -> V?")]
public static MRubyValue InternalDelete(MRubyState state, MRubyValue self)
Expand Down
Loading
Loading