[Performance] Vectorize string.StartsWith for static readonly strings#123755
[Performance] Vectorize string.StartsWith for static readonly strings#123755reedz wants to merge 2 commits intodotnet:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request adds Value Numbering (VN)-based intrinsic expansion for String.StartsWith to vectorize cases where the comparison string is a frozen/immutable string from a static readonly field. The existing import-time vectorization only handles string literals, but this enhancement allows vectorization when the JIT can prove through VN that a string argument points to a frozen object.
Changes:
- Adds VN-based expansion logic to detect and vectorize
StartsWithcalls with frozen strings - Marks
StartsWithas a special intrinsic even when import-time vectorization fails, allowing later VN-based optimization - Extends the special intrinsic framework to handle
NI_System_String_StartsWithalongside the existingReadUtf8expansion
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/coreclr/jit/morph.cpp | Adds NI_System_String_StartsWith to the special intrinsic check alongside ReadUtf8 |
| src/coreclr/jit/importercalls.cpp | Marks StartsWith as special intrinsic even when import-time vectorization fails |
| src/coreclr/jit/helperexpansion.cpp | Implements fgVNBasedIntrinsicExpansionForCall_StringComparison to expand StartsWith with frozen strings |
| src/coreclr/jit/compiler.h | Adds function declaration for the new string comparison expansion |
src/coreclr/jit/helperexpansion.cpp
Outdated
| JITDUMP("StringComparison: string exceeds max buffer size\n") | ||
| return false; | ||
| } | ||
|
|
There was a problem hiding this comment.
The variable strObjOffset retrieved from GetObjectHandleAndOffset is not validated or used. For a string object reference (the expected case), this offset should be 0. However, unlike the similar fgVNBasedIntrinsicExpansionForCall_ReadUtf8 function which validates strObjOffset <= INT_MAX, this function doesn't validate the offset value. Consider adding a check such as if (strObjOffset != 0) { JITDUMP(...); return false; } to ensure the VN represents a direct string object reference and not some other pointer pattern.
| if (strObjOffset != 0) | |
| { | |
| JITDUMP("StringComparison: unexpected string object offset %d\n", (int)strObjOffset) | |
| return false; | |
| } |
src/coreclr/jit/helperexpansion.cpp
Outdated
| StringComparison cmpMode = Ordinal; | ||
| if (argCount == 3) | ||
| { | ||
| GenTree* comparisonArg = call->gtArgs.GetUserArgByIndex(2)->GetNode(); | ||
| if (!comparisonArg->IsIntegralConst()) | ||
| { | ||
| JITDUMP("StringComparison: comparison type is not constant\n") | ||
| return false; | ||
| } | ||
|
|
||
| ssize_t cmpValue = comparisonArg->AsIntCon()->IconValue(); | ||
| if (cmpValue == Ordinal) | ||
| { | ||
| cmpMode = Ordinal; | ||
| } | ||
| else if (cmpValue == OrdinalIgnoreCase) | ||
| { | ||
| cmpMode = OrdinalIgnoreCase; | ||
| } | ||
| else | ||
| { | ||
| JITDUMP("StringComparison: unsupported comparison type %zd\n", cmpValue) | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
When argCount == 2, this corresponds to the StartsWith(string value) overload which uses StringComparison.CurrentCulture semantics (not Ordinal). The code defaults to Ordinal mode, which is incorrect for this overload. While this overload should normally be inlined (converting it to a 3-arg call with CurrentCulture), if inlining fails, the optimization would proceed with incorrect semantics. Consider adding an explicit check: if (argCount == 2) { JITDUMP("2-arg StartsWith uses CurrentCulture, cannot vectorize\n"); return false; }.
|
I think the pattern in question is already handled by #121985 ? |
Ah right. private static readonly string s_prefix = "/api/data";
static bool Test(string str) => str.StartsWith(s_prefix, StringComparison.Ordinal);I see that we unroll this case even today, because This logic effectively duplicates Do you have a benchmark for this? |
|
@EgorBo upon closer inspection it seems it makes more sense to move frozen object handling logic to importer vectorization rather than handling individual cases as was done previously - I've updated the PR with this change. The benchmarks seem to be showing ~50% performance improvement for the following: using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(StartsWithBenchmark).Assembly).Run(args);
[MemoryDiagnoser]
public class StartsWithBenchmark
{
private static readonly string s_shortPrefix = "/api";
private static readonly string s_mediumPrefix = "/api/data/v1";
private static readonly string s_longPrefix = "/api/data/v1/users/profile";
private string _matchingShort = default!;
private string _matchingMedium = default!;
private string _matchingLong = default!;
private string _nonMatching = default!;
[GlobalSetup]
public void Setup()
{
_matchingShort = "/api/users/123";
_matchingMedium = "/api/data/v1/items/456";
_matchingLong = "/api/data/v1/users/profile/settings";
_nonMatching = "/home/index.html";
}
[Benchmark]
[MethodImpl(MethodImplOptions.NoInlining)]
public bool Short_Match() =>
_matchingShort.StartsWith(s_shortPrefix, StringComparison.Ordinal);
[Benchmark]
[MethodImpl(MethodImplOptions.NoInlining)]
public bool Medium_Match() =>
_matchingMedium.StartsWith(s_mediumPrefix, StringComparison.Ordinal);
[Benchmark]
[MethodImpl(MethodImplOptions.NoInlining)]
public bool Long_Match() =>
_matchingLong.StartsWith(s_longPrefix, StringComparison.Ordinal);
[Benchmark]
[MethodImpl(MethodImplOptions.NoInlining)]
public bool Short_NoMatch() =>
_nonMatching.StartsWith(s_shortPrefix, StringComparison.Ordinal);
} |
|
The PR doesn't handle the same case for Span. |
| { | ||
| return nullptr; | ||
| } | ||
| if (!info.compCompHnd->getObjectContent(objHnd, (uint8_t*)str, (int)(cnsLength * sizeof(char16_t)), |
There was a problem hiding this comment.
the code seems to have an assumption that the field is a string. I don't see how that assumption is validated
|
|
||
| auto isFrozenObjHandle = [](GenTree* node) -> bool { | ||
| return node->IsIconHandle(GTF_ICON_OBJ_HDL) && node->TypeIs(TYP_REF); | ||
| }; |
There was a problem hiding this comment.
this looks like an unnecessary use use of a lambda. node->TypeIs(TYP_REF) is redundant. GTF_ICON_OBJ_HDL implies it's a frozen object
Fixes #84525.
Related work/discussions: #84524, #48160, #61733.
Existing vectorization deals with literals, but not static read-only string fields. Following how VN-based intrinsic expansion is done for in
fgVNBasedIntrinsicExpansionForCall_ReadUtf8, I'm addingfgVNBasedIntrinsicExpansionForCall_StringComparisonthat vectorizes cases as shown in #84525:I have quick and dirty benchmarks suggesting that string.StartsWith(s_readonlyFieldString) have been significantly sped up, but I'd rely on bots to prove me right or wrong.