Skip to content

Commit 6320185

Browse files
committed
Use code-generator for [FastHash].
1 parent d82c9a4 commit 6320185

File tree

10 files changed

+309
-180
lines changed

10 files changed

+309
-180
lines changed

Directory.Packages.props

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
<PackageVersion Include="System.Threading.Channels" Version="5.0.0" />
99
<PackageVersion Include="System.Runtime.InteropServices.RuntimeInformation" Version="4.3.0" />
1010
<PackageVersion Include="System.IO.Compression" Version="4.3.0" />
11+
12+
<!-- For analyzers, tied to the consumer's build SDK; at the moment, that means "us" -->
13+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
14+
1115
<!-- Packages only used in the solution, upgrade at will -->
1216
<PackageVersion Include="BenchmarkDotNet" Version="0.15.2" />
1317
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
@@ -23,6 +27,7 @@
2327
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
2428
<PackageVersion Include="System.Collections.Immutable" Version="9.0.0" />
2529
<PackageVersion Include="System.Reflection.Metadata" Version="9.0.0" />
30+
2631
<!-- For binding redirect testing, main package gets this transitively -->
2732
<PackageVersion Include="System.IO.Pipelines" Version="9.0.0" />
2833
<PackageVersion Include="System.Runtime.Caching" Version="9.0.0" />

StackExchange.Redis.sln

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs", "docs\docs.csproj",
122122
EndProject
123123
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackExchange.Redis.Benchmarks", "tests\StackExchange.Redis.Benchmarks\StackExchange.Redis.Benchmarks.csproj", "{59889284-FFEE-82E7-94CB-3B43E87DA6CF}"
124124
EndProject
125+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eng", "eng", "{5FA0958E-6EBD-45F4-808E-3447A293F96F}"
126+
EndProject
127+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackExchange.Redis.Build", "eng\StackExchange.Redis.Build\StackExchange.Redis.Build.csproj", "{190742E1-FA50-4E36-A8C4-88AE87654340}"
128+
EndProject
125129
Global
126130
GlobalSection(SolutionConfigurationPlatforms) = preSolution
127131
Debug|Any CPU = Debug|Any CPU
@@ -180,6 +184,10 @@ Global
180184
{59889284-FFEE-82E7-94CB-3B43E87DA6CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
181185
{59889284-FFEE-82E7-94CB-3B43E87DA6CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
182186
{59889284-FFEE-82E7-94CB-3B43E87DA6CF}.Release|Any CPU.Build.0 = Release|Any CPU
187+
{190742E1-FA50-4E36-A8C4-88AE87654340}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
188+
{190742E1-FA50-4E36-A8C4-88AE87654340}.Debug|Any CPU.Build.0 = Debug|Any CPU
189+
{190742E1-FA50-4E36-A8C4-88AE87654340}.Release|Any CPU.ActiveCfg = Release|Any CPU
190+
{190742E1-FA50-4E36-A8C4-88AE87654340}.Release|Any CPU.Build.0 = Release|Any CPU
183191
EndGlobalSection
184192
GlobalSection(SolutionProperties) = preSolution
185193
HideSolutionNode = FALSE
@@ -202,6 +210,7 @@ Global
202210
{A0F89B8B-32A3-4C28-8F1B-ADE343F16137} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
203211
{69A0ACF2-DF1F-4F49-B554-F732DCA938A3} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
204212
{59889284-FFEE-82E7-94CB-3B43E87DA6CF} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
213+
{190742E1-FA50-4E36-A8C4-88AE87654340} = {5FA0958E-6EBD-45F4-808E-3447A293F96F}
205214
EndGlobalSection
206215
GlobalSection(ExtensibilityGlobals) = postSolution
207216
SolutionGuid = {193AA352-6748-47C1-A5FC-C9AA6B5F000B}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using System.Buffers;
2+
using System.Collections.Immutable;
3+
using System.Reflection;
4+
using System.Text;
5+
using Microsoft.CodeAnalysis;
6+
using Microsoft.CodeAnalysis.CSharp;
7+
using Microsoft.CodeAnalysis.CSharp.Syntax;
8+
9+
namespace StackExchange.Redis.Build;
10+
11+
[Generator(LanguageNames.CSharp)]
12+
public class FastHashGenerator : IIncrementalGenerator
13+
{
14+
public void Initialize(IncrementalGeneratorInitializationContext context)
15+
{
16+
var literals = context.SyntaxProvider
17+
.CreateSyntaxProvider(Predicate, Transform)
18+
.Where(pair => pair.Name is { Length: > 0 })
19+
.Collect();
20+
21+
context.RegisterSourceOutput(literals, Generate);
22+
}
23+
24+
private bool Predicate(SyntaxNode node, CancellationToken cancellationToken)
25+
{
26+
// looking for [FastHash] partial static class Foo { }
27+
if (node is ClassDeclarationSyntax decl
28+
&& decl.Modifiers.Any(SyntaxKind.StaticKeyword)
29+
&& decl.Modifiers.Any(SyntaxKind.PartialKeyword))
30+
{
31+
foreach (var attribList in decl.AttributeLists)
32+
{
33+
foreach (var attrib in attribList.Attributes)
34+
{
35+
if (attrib.Name.ToString() is "FastHashAttribute" or "FastHash") return true;
36+
}
37+
}
38+
}
39+
40+
return false;
41+
}
42+
43+
private (string Namespace, string ParentType, string Name, string Value) Transform(
44+
GeneratorSyntaxContext ctx,
45+
CancellationToken cancellationToken)
46+
{
47+
// extract the name and value (defaults to name, but can be overridden via attribute) and the location
48+
if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) is not INamedTypeSymbol named) return default;
49+
string ns = "", parentType = "";
50+
if (named.ContainingType is { } containingType)
51+
{
52+
parentType = containingType.Name; // don't worry about multi-level nesting for now; add later if needed
53+
ns = containingType.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat);
54+
}
55+
else if (named.ContainingNamespace is { } containingNamespace)
56+
{
57+
ns = containingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat);
58+
}
59+
60+
string name = named.Name, value = "";
61+
foreach (var attrib in named.GetAttributes())
62+
{
63+
if (attrib.AttributeClass?.Name == "FastHashAttribute")
64+
{
65+
if (attrib.ConstructorArguments.Length == 1)
66+
{
67+
if (attrib.ConstructorArguments[0].Value?.ToString() is { Length: > 0 } val)
68+
{
69+
value = val;
70+
break;
71+
}
72+
}
73+
}
74+
}
75+
76+
if (string.IsNullOrWhiteSpace(value))
77+
{
78+
value = name.Replace("_", "-"); // if nothing explicit: infer from name
79+
}
80+
81+
return (ns, parentType, name, value);
82+
}
83+
84+
private string GetVersion()
85+
{
86+
var asm = GetType().Assembly;
87+
if (asm.GetCustomAttributes(typeof(AssemblyFileVersionAttribute), false).FirstOrDefault() is
88+
AssemblyFileVersionAttribute { Version: { Length: > 0 } } version)
89+
{
90+
return version.Version;
91+
}
92+
93+
return asm.GetName().Version?.ToString() ?? "??";
94+
}
95+
96+
private void Generate(
97+
SourceProductionContext ctx,
98+
ImmutableArray<(string Namespace, string ParentType, string Name, string Value)> literals)
99+
{
100+
if (literals.IsDefaultOrEmpty) return;
101+
102+
var sb = new StringBuilder("// <auto-generated />")
103+
.AppendLine().Append("// ").Append(GetType().Name).Append(" v").Append(GetVersion()).AppendLine();
104+
105+
// lease a buffer that is big enough for the longest string
106+
var buffer = ArrayPool<byte>.Shared.Rent(
107+
Encoding.UTF8.GetMaxByteCount(literals.Max(l => l.Value.Length)));
108+
int indent = 0;
109+
110+
StringBuilder NewLine() => sb.AppendLine().Append(' ', indent * 4);
111+
NewLine().Append("using System;");
112+
NewLine().Append("using StackExchange.Redis;");
113+
NewLine().Append("#pragma warning disable CS8981");
114+
foreach (var grp in literals.GroupBy(l => (l.Namespace, l.ParentType)))
115+
{
116+
NewLine();
117+
if (!string.IsNullOrWhiteSpace(grp.Key.Namespace))
118+
{
119+
NewLine().Append("namespace ").Append(grp.Key.Namespace);
120+
NewLine().Append("{");
121+
indent++;
122+
}
123+
if (!string.IsNullOrWhiteSpace(grp.Key.ParentType))
124+
{
125+
NewLine().Append("partial class ").Append(grp.Key.ParentType);
126+
NewLine().Append("{");
127+
indent++;
128+
}
129+
130+
foreach (var literal in grp)
131+
{
132+
int len;
133+
unsafe
134+
{
135+
fixed (byte* bPtr = buffer) // netstandard2.0 forces fallback API
136+
{
137+
fixed (char* cPtr = literal.Value)
138+
{
139+
len = Encoding.UTF8.GetBytes(cPtr, literal.Value.Length, bPtr, buffer.Length);
140+
}
141+
}
142+
}
143+
144+
var hash = FastHash.Hash64(buffer.AsSpan(0, len));
145+
NewLine().Append("static partial class ").Append(literal.Name);
146+
NewLine().Append("{");
147+
indent++;
148+
NewLine().Append("public const int Length = ").Append(len).Append(';');
149+
NewLine().Append("public const long Hash = ").Append(hash).Append(';');
150+
NewLine().Append("public static ReadOnlySpan<byte> U8 => @\"")
151+
.Append(literal.Value.Replace("\"", "\"\"")).Append("\"u8;");
152+
NewLine().Append("public static string Text => @\"")
153+
.Append(literal.Value.Replace("\"", "\"\"")).Append("\";");
154+
NewLine().Append("public static bool Is(long hash, in RawResult value) => hash == Hash && value.IsEqual(U8);");
155+
indent--;
156+
NewLine().Append("}");
157+
}
158+
159+
if (!string.IsNullOrWhiteSpace(grp.Key.ParentType))
160+
{
161+
indent--;
162+
NewLine().Append("}");
163+
}
164+
if (!string.IsNullOrWhiteSpace(grp.Key.Namespace))
165+
{
166+
indent--;
167+
NewLine().Append("}");
168+
}
169+
}
170+
171+
ArrayPool<byte>.Shared.Return(buffer);
172+
ctx.AddSource("FastHash.generated.cs", sb.ToString());
173+
}
174+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# FastHashGenerator
2+
3+
Efficient matching of well-known short string tokens is a high-volume scenario, for example when matching RESP literals.
4+
5+
The purpose of this generator is to interpret inputs like:
6+
7+
``` c#
8+
[FastHash] public static partial class bin { }
9+
[FastHash] public static partial class f32 { }
10+
```
11+
12+
Usually the token is inferred from the name; `[FastHash("real value")]` can be used if the token is not a valid identifier.
13+
Underscore is replaced with hyphen, so a field called `my_token` has the default value `"my-token"`.
14+
The generator demands *all* of `[FastHash] public static partial class`.
15+
16+
The output is of the form:
17+
18+
``` c#
19+
static partial class bin
20+
{
21+
public const int Length = 3;
22+
public const long Hash = 7235938;
23+
public static ReadOnlySpan<byte> U8 => @"bin"u8;
24+
public static string Text => @"bin";
25+
public static bool Is(long hash, in RawResult value) => hash == Hash && value.IsEqual(U8);
26+
}
27+
static partial class f32
28+
{
29+
public const int Length = 3;
30+
public const long Hash = 3289958;
31+
public static ReadOnlySpan<byte> U8 => @"f32"u8;
32+
public static string Text => @"f32";
33+
public static bool Is(long hash, in RawResult value) => hash == Hash && value.IsEqual(U8);
34+
}
35+
```
36+
37+
This allows for fast, efficient, and safe matching of well-known tokens, for example:
38+
39+
``` c#
40+
var key = ...
41+
var hash = key.Hash64();
42+
switch (key.Length)
43+
{
44+
case bin.Length when bin.Is(hash, key):
45+
// handle bin
46+
break;
47+
case f32.Length when f32.Is(hash, key):
48+
// handle f32
49+
break;
50+
}
51+
```
52+
53+
The switch on the `Length` is optional, but recommended - these low values can often be implemented (by the compiler)
54+
as a simple jump-table, which is very fast. However, switching on the hash itself is also valid. All hash matches
55+
must also perform a sequence equality check - the `Is` convenient method validates both hash and equality.
56+
57+
58+
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all"/>
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<Compile Include="..\..\src\StackExchange.Redis\FastHash.cs">
16+
<Link>FastHash.cs</Link>
17+
</Compile>
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 13 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,20 @@
1-
using System;
2-
using System.Diagnostics.CodeAnalysis;
1+
using System.Diagnostics.CodeAnalysis;
32

43
namespace StackExchange.Redis;
54

6-
// See FastHashTests for how these are validated and enforced. When adding new values, use any
7-
// value and run the tests - this will tell you the correct value.
85
[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "To better represent the expected literals")]
96
internal static partial class FastHash
107
{
11-
#pragma warning disable SA1300, SA1303
12-
public static class _3
13-
{
14-
public const long bin = 7235938;
15-
public static ReadOnlySpan<byte> bin_u8 => "bin"u8;
16-
17-
public const long f32 = 3289958;
18-
public static ReadOnlySpan<byte> f32_u8 => "f32"u8;
19-
}
20-
21-
public static class _4
22-
{
23-
public const long size = 1702521203;
24-
public static ReadOnlySpan<byte> size_u8 => "size"u8;
25-
26-
public const long int8 = 947154537;
27-
public static ReadOnlySpan<byte> int8_u8 => "int8"u8;
28-
}
29-
30-
public static class _8
31-
{
32-
public const long vset_uid = 7235443114434196342;
33-
public static ReadOnlySpan<byte> vset_uid_u8 => "vset-uid"u8;
34-
}
35-
36-
public static class _9
37-
{
38-
public const long max_level = 7311142560376316269;
39-
public static ReadOnlySpan<byte> max_level_u8 => "max-level"u8;
40-
}
41-
42-
public static class _10
43-
{
44-
public const long quant_type = 8751669953979053425;
45-
public static ReadOnlySpan<byte> quant_type_u8 => "quant-type"u8;
46-
47-
public const long vector_dim = 7218551600764380534;
48-
public static ReadOnlySpan<byte> vector_dim_u8 => "vector-dim"u8;
49-
}
50-
51-
public static class _17
52-
{
53-
public const long hnsw_max_node_uid = 8674334399337295464;
54-
public static ReadOnlySpan<byte> hnsw_max_node_uid_u8 => "hnsw-max-node-uid"u8;
55-
}
56-
#pragma warning restore SA1300, SA1303
8+
// see HastHashGenerator.md for more information and intended usage.
9+
#pragma warning disable CS8981, SA1134, SA1300, SA1303, SA1502
10+
[FastHash] public static partial class bin { }
11+
[FastHash] public static partial class f32 { }
12+
[FastHash] public static partial class int8 { }
13+
[FastHash] public static partial class size { }
14+
[FastHash] public static partial class vset_uid { }
15+
[FastHash] public static partial class max_level { }
16+
[FastHash] public static partial class quant_type { }
17+
[FastHash] public static partial class vector_dim { }
18+
[FastHash] public static partial class hnsw_max_node_uid { }
19+
#pragma warning restore CS8981, SA1134, SA1300, SA1303, SA1502
5720
}

src/StackExchange.Redis/FastHash.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ to construct strings when parsing tokens.
2828
*/
2929
internal static partial class FastHash
3030
{
31-
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = false)]
32-
public sealed class LiteralAttribute(string token) : Attribute
31+
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
32+
public sealed class FastHashAttribute(string token = "") : Attribute
3333
{
3434
public string Token => token;
3535
}

0 commit comments

Comments
 (0)