Skip to content

Commit 29272f0

Browse files
committed
optimize CommandMap by pre-generating all the RESP chunks
1 parent d8be7b6 commit 29272f0

6 files changed

Lines changed: 230 additions & 25 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
#nullable enable
22
[SER004]RESPite.Messages.RespReader.TryParseScalar<T>(RESPite.Messages.RespReader.ScalarParser<char, T>! parser, out T value) -> bool
3+
[SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan<byte> first, System.ReadOnlySpan<char> second) -> bool
4+
[SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan<char> first, System.ReadOnlySpan<byte> second) -> bool
5+
[SER004]static RESPite.AsciiHash.SequenceEqualsCI(System.ReadOnlySpan<byte> first, System.ReadOnlySpan<char> second) -> bool
6+
[SER004]static RESPite.AsciiHash.SequenceEqualsCI(System.ReadOnlySpan<char> first, System.ReadOnlySpan<byte> second) -> bool

src/RESPite/Shared/AsciiHash.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ public static bool EqualsCI(ReadOnlySpan<byte> first, ReadOnlySpan<byte> second)
8282
return len <= MaxBytesHashed ? HashUC(first) == HashUC(second) : SequenceEqualsCI(first, second);
8383
}
8484

85+
public static bool EqualsCI(ReadOnlySpan<byte> first, ReadOnlySpan<char> second)
86+
=> EqualsCI(second, first);
87+
8588
public static unsafe bool SequenceEqualsCI(ReadOnlySpan<byte> first, ReadOnlySpan<byte> second)
8689
{
8790
var len = first.Length;
@@ -117,6 +120,9 @@ public static unsafe bool SequenceEqualsCI(ReadOnlySpan<byte> first, ReadOnlySpa
117120
}
118121
}
119122

123+
public static bool SequenceEqualsCI(ReadOnlySpan<byte> first, ReadOnlySpan<char> second)
124+
=> SequenceEqualsCI(second, first);
125+
120126
public static bool EqualsCS(ReadOnlySpan<char> first, ReadOnlySpan<char> second)
121127
{
122128
var len = first.Length;
@@ -136,6 +142,14 @@ public static bool EqualsCI(ReadOnlySpan<char> first, ReadOnlySpan<char> second)
136142
return len <= MaxBytesHashed ? HashUC(first) == HashUC(second) : SequenceEqualsCI(first, second);
137143
}
138144

145+
public static bool EqualsCI(ReadOnlySpan<char> first, ReadOnlySpan<byte> second)
146+
{
147+
var len = first.Length;
148+
if (len != second.Length) return false;
149+
// for very short values, the UC hash performs CI equality
150+
return len <= MaxBytesHashed ? HashUC(first) == HashUC(second) : SequenceEqualsCI(first, second);
151+
}
152+
139153
public static unsafe bool SequenceEqualsCI(ReadOnlySpan<char> first, ReadOnlySpan<char> second)
140154
{
141155
var len = first.Length;
@@ -171,6 +185,41 @@ public static unsafe bool SequenceEqualsCI(ReadOnlySpan<char> first, ReadOnlySpa
171185
}
172186
}
173187

188+
public static unsafe bool SequenceEqualsCI(ReadOnlySpan<char> first, ReadOnlySpan<byte> second)
189+
{
190+
var len = first.Length;
191+
if (len != second.Length) return false;
192+
193+
// OK, don't be clever (SIMD, etc); the purpose of FashHash is to compare RESP key tokens, which are
194+
// typically relatively short, think 3-20 bytes. That wouldn't even touch a SIMD vector, so:
195+
// just loop (the exact thing we'd need to do *anyway* in a SIMD implementation, to mop up the non-SIMD
196+
// trailing bytes).
197+
fixed (char* firstPtr = &MemoryMarshal.GetReference(first))
198+
{
199+
fixed (byte* secondPtr = &MemoryMarshal.GetReference(second))
200+
{
201+
const int CS_MASK = 0b0101_1111;
202+
for (int i = 0; i < len; i++)
203+
{
204+
int x = (byte)firstPtr[i];
205+
var xCI = x & CS_MASK;
206+
if (xCI >= 'A' & xCI <= 'Z')
207+
{
208+
// alpha mismatch
209+
if (xCI != (secondPtr[i] & CS_MASK)) return false;
210+
}
211+
else if (x != secondPtr[i])
212+
{
213+
// non-alpha mismatch
214+
return false;
215+
}
216+
}
217+
218+
return true;
219+
}
220+
}
221+
}
222+
174223
public static void Hash(scoped ReadOnlySpan<byte> value, out long cs, out long uc)
175224
{
176225
cs = HashCS(value);

src/StackExchange.Redis/CommandMap.cs

Lines changed: 119 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Runtime.CompilerServices;
34
using System.Text;
45
using RESPite;
56

@@ -10,9 +11,14 @@ namespace StackExchange.Redis
1011
/// </summary>
1112
public sealed class CommandMap
1213
{
13-
private readonly AsciiHash[] map;
14+
private readonly CommandBytes[] map;
15+
private readonly byte[] bytes;
1416

15-
internal CommandMap(AsciiHash[] map) => this.map = map;
17+
private CommandMap(CommandBytes[] map, byte[] bytes)
18+
{
19+
this.map = map;
20+
this.bytes = bytes;
21+
}
1622

1723
/// <summary>
1824
/// The default commands specified by redis.
@@ -184,22 +190,31 @@ internal void AppendDeltas(StringBuilder sb)
184190
var knownCmd = all[i];
185191
if (knownCmd is RedisCommand.UNKNOWN) continue;
186192
var keyString = knownCmd.ToString();
187-
var keyBytes = new AsciiHash(keyString);
188193
var value = map[i];
189-
if (!keyBytes.Equals(value))
194+
var valueBytes = value.GetCommandBytes(bytes);
195+
if (!AsciiHash.EqualsCI(keyString, valueBytes))
190196
{
191197
if (sb.Length != 0) sb.Append(',');
192-
sb.Append('$').Append(keyString).Append('=').Append(value);
198+
sb.Append('$').Append(keyString).Append('=');
199+
if (!valueBytes.IsEmpty)
200+
{
201+
sb.Append(Encoding.ASCII.GetString(valueBytes));
202+
}
193203
}
194204
}
195205
}
196206

197207
internal void AssertAvailable(RedisCommand command)
198208
{
199-
if (map[(int)command].IsEmpty) throw ExceptionFactory.CommandDisabled(command);
209+
if (map[(int)command].IsEmpty) ThrowCommandDisabled(command);
210+
211+
[MethodImpl(MethodImplOptions.NoInlining)]
212+
static void ThrowCommandDisabled(RedisCommand command) => throw ExceptionFactory.CommandDisabled(command);
200213
}
201214

202-
internal AsciiHash GetBytes(RedisCommand command) => map[(int)command];
215+
internal ReadOnlySpan<byte> GetCommandBytes(RedisCommand command) => map[(int)command].GetCommandBytes(bytes);
216+
217+
internal ReadOnlySpan<byte> GetResp(RedisCommand command) => map[(int)command].GetResp(bytes);
203218

204219
internal bool IsAvailable(RedisCommand command) => !map[(int)command].IsEmpty;
205220

@@ -211,27 +226,107 @@ private static CommandMap CreateImpl(Dictionary<string, string?>? caseInsensitiv
211226
{
212227
var commands = AllCommands;
213228

214-
// todo: optimize and support ad-hoc overrides/disables, and shared buffer rather than multiple arrays
215-
var map = new AsciiHash[commands.Length];
229+
int totalLength = 0;
216230
for (int i = 0; i < commands.Length; i++)
217231
{
218-
int idx = (int)commands[i];
219-
string? name = commands[i].ToString(), value = name;
232+
var value = GetCommandValue(commands[i], caseInsensitiveOverrides, exclusions);
233+
if (string.IsNullOrEmpty(value)) continue;
220234

221-
if (commands[i] is RedisCommand.UNKNOWN || exclusions?.Contains(commands[i]) == true)
222-
{
223-
map[idx] = default;
224-
}
225-
else
226-
{
227-
if (caseInsensitiveOverrides != null && caseInsensitiveOverrides.TryGetValue(name, out string? tmp))
228-
{
229-
value = tmp?.ToUpperInvariant();
230-
}
231-
map[idx] = new AsciiHash(value);
232-
}
235+
totalLength += GetBulkStringLength(Encoding.ASCII.GetByteCount(value));
236+
}
237+
238+
// Store all mapped command names as RESP bulk-string fragments in one buffer - everything is then
239+
// ready to throw directly into the stream.
240+
var map = new CommandBytes[commands.Length];
241+
242+
// Currently (8.8-ish) this is approx 3k; that's very reasonable to avoid a ton of CPU cycles in
243+
// the most common write path.
244+
var bytes = totalLength == 0 ? Array.Empty<byte>() : new byte[totalLength];
245+
int offset = 0;
246+
for (int i = 0; i < commands.Length; i++)
247+
{
248+
var command = commands[i];
249+
var value = GetCommandValue(command, caseInsensitiveOverrides, exclusions);
250+
if (string.IsNullOrEmpty(value)) continue;
251+
252+
int payloadLength = Encoding.ASCII.GetByteCount(value);
253+
int respLength = GetBulkStringLength(payloadLength);
254+
map[(int)command] = new CommandBytes(offset, respLength, GetPayloadOffset(payloadLength));
255+
256+
var span = bytes.AsSpan(offset, respLength);
257+
span[0] = (byte)'$';
258+
int payloadOffset = MessageWriter.WriteRaw(span, payloadLength, offset: 1);
259+
var payload = span.Slice(payloadOffset, payloadLength);
260+
int written = Encoding.ASCII.GetBytes(value.AsSpan(), payload);
261+
if (written != payloadLength) ThrowAsciiEncodeLengthCheckFailure();
262+
AsciiHash.ToUpper(payload);
263+
MessageWriter.WriteCrlf(span, payloadOffset + payloadLength);
264+
offset += respLength;
265+
}
266+
return new CommandMap(map, bytes);
267+
268+
[MethodImpl(MethodImplOptions.NoInlining)]
269+
static void ThrowAsciiEncodeLengthCheckFailure() => throw new InvalidOperationException("ASCII encode length check failure");
270+
}
271+
272+
private static string? GetCommandValue(
273+
RedisCommand command,
274+
Dictionary<string, string?>? caseInsensitiveOverrides,
275+
HashSet<RedisCommand>? exclusions)
276+
{
277+
if (command is RedisCommand.UNKNOWN || exclusions?.Contains(command) == true) return null;
278+
279+
var name = command.ToString();
280+
if (caseInsensitiveOverrides != null && caseInsensitiveOverrides.TryGetValue(name, out string? value))
281+
{
282+
return value;
283+
}
284+
285+
return name;
286+
}
287+
288+
// ${N}\r\n{RAW}\r\n
289+
private static int GetBulkStringLength(int payloadLength) => 5 + GetDigitCount(payloadLength) + payloadLength;
290+
291+
// ${N}\r\n
292+
private static byte GetPayloadOffset(int payloadLength) => checked((byte)(3 + GetDigitCount(payloadLength)));
293+
294+
private static int GetDigitCount(int value)
295+
{
296+
if (value < 10) return 1;
297+
if (value < 100) return 2;
298+
int digits = 1;
299+
while ((value /= 10) != 0)
300+
{
301+
digits++;
302+
}
303+
return digits;
304+
}
305+
306+
private readonly struct CommandBytes(int offset, int length, byte payloadOffset)
307+
{
308+
// Tracks position inside a shared buffer; given
309+
// $3\r\nFOO\r\n$3\r\nBAR\r\n we have the positions (for BAR):
310+
// ^ a ^ b ^c
311+
// We know that the trailer is always exactly 2 bytes, so we don't need to store the
312+
// length of the command itself - we can infer from the other values.
313+
// offset is a, payloadOffset is a-to-b, length is a-to-c
314+
private readonly uint offset = checked((uint)offset);
315+
private readonly ushort length = checked((ushort)length);
316+
317+
public bool IsEmpty => length == 0;
318+
319+
// this will be fine even for a default instance
320+
public ReadOnlySpan<byte> GetResp(byte[] bytes) => new(bytes, (int)offset, length);
321+
322+
public ReadOnlySpan<byte> GetCommandBytes(byte[] bytes)
323+
{
324+
if (IsEmpty) return default;
325+
return new ReadOnlySpan<byte>(
326+
bytes,
327+
checked((int)offset) + payloadOffset,
328+
length - payloadOffset - 2);
233329
}
234-
return new CommandMap(map);
235330
}
236331
}
237332
}

src/StackExchange.Redis/MessageWriter.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,28 @@ internal void WriteHeader(string command, int arguments)
131131
}
132132
}
133133

134-
internal void WriteHeader(RedisCommand command, int arguments) => WriteHeader(command, arguments, _map.GetBytes(command).Span);
134+
internal void WriteHeader(RedisCommand command, int arguments)
135+
{
136+
// using >= here because we will be adding 1 for the command itself (which is an arg for the purposes of the multi-bulk protocol)
137+
if (arguments >= REDIS_MAX_ARGS) throw ExceptionFactory.TooManyArgs(command.ToString(), arguments);
138+
139+
// in theory we should never see this; CheckMessage dealt with "regular" messages, and
140+
// ExecuteMessage should have dealt with everything else
141+
var commandBytes = _map.GetResp(command);
142+
if (commandBytes.IsEmpty) throw ExceptionFactory.CommandDisabled(command);
143+
144+
// *{argCount}\r\n = 3 + MaxInt32TextLen
145+
// ${cmd-len}\r\n = precomputed
146+
// {cmd}\r\n = precomputed
147+
var span = _writer.GetSpan(commandBytes.Length + 3 + Format.MaxInt32TextLen);
148+
span[0] = (byte)'*';
149+
150+
int offset = WriteRaw(span, arguments + 1, offset: 1);
151+
commandBytes.CopyTo(span.Slice(offset));
152+
offset += commandBytes.Length;
153+
154+
_writer.Advance(offset);
155+
}
135156

136157
internal void WriteHeader(RedisCommand command, int arguments, ReadOnlySpan<byte> commandBytes)
137158
{

tests/StackExchange.Redis.Tests/AsciiHashUnitTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,33 @@ public void CaseInsensitiveEquality(string text)
150150
Assert.Equal(hashLowerUC, hashUpperUC);
151151
}
152152

153+
[Theory]
154+
[InlineData("a")] // length 1
155+
[InlineData("ab")] // length 2
156+
[InlineData("abc")] // length 3
157+
[InlineData("abcd")] // length 4
158+
[InlineData("abcde")] // length 5
159+
[InlineData("abcdef")] // length 6
160+
[InlineData("abcdefg")] // length 7
161+
[InlineData("abcdefgh")] // length 8
162+
[InlineData("abcdefghi")] // length 9
163+
[InlineData("abcdefghij")] // length 10
164+
[InlineData("abcdefghijklmnop")] // length 16
165+
[InlineData("abcdefghijklmnopqrst")] // length 20
166+
public void CaseInsensitiveEquality_MixedBytesAndChars(string text)
167+
{
168+
var lowerChars = text.AsSpan();
169+
var upperBytes = Encoding.UTF8.GetBytes(text.ToUpperInvariant());
170+
171+
Assert.True(AsciiHash.EqualsCI(lowerChars, upperBytes), "CI: chars lower == bytes upper");
172+
Assert.True(AsciiHash.EqualsCI(upperBytes, lowerChars), "CI: bytes upper == chars lower");
173+
174+
Assert.True(AsciiHash.SequenceEqualsCI(lowerChars, upperBytes), "CI sequence: chars lower == bytes upper");
175+
Assert.True(AsciiHash.SequenceEqualsCI(upperBytes, lowerChars), "CI sequence: bytes upper == chars lower");
176+
177+
Assert.False(AsciiHash.EqualsCI((text + "x").AsSpan(), upperBytes), "CI: length mismatch");
178+
}
179+
153180
[Theory]
154181
[InlineData("a")] // length 1
155182
[InlineData("ab")] // length 2

tests/StackExchange.Redis.Tests/TestHarnessTests.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ public void WithKeyPrefix()
5353
resp.ValidateResp("*3\r\n$3\r\nPUT\r\n$9\r\n123/mykey\r\n$2\r\n42\r\n", "set", args);
5454
}
5555

56+
[Fact]
57+
public void CommandMapDeltas()
58+
{
59+
Assert.Equal("", CommandMap.Default.ToString());
60+
Assert.Equal("", CommandMap.Create(new() { ["sEt"] = "set" }).ToString());
61+
Assert.Equal("$SET=PUT", CommandMap.Create(new() { ["sEt"] = "put" }).ToString());
62+
Assert.Equal("$ECHO=", CommandMap.Create(new() { "echo" }, available: false).ToString());
63+
}
64+
5665
[Fact]
5766
public void WithKeyPrefix_DetectIncorrectUsage()
5867
{

0 commit comments

Comments
 (0)