Skip to content

[Performance] Vectorize string.StartsWith for static readonly strings#123755

Closed
reedz wants to merge 2 commits into
dotnet:mainfrom
reedz:feature/fold-path-string-pattern
Closed

[Performance] Vectorize string.StartsWith for static readonly strings#123755
reedz wants to merge 2 commits into
dotnet:mainfrom
reedz:feature/fold-path-string-pattern

Conversation

@reedz

@reedz reedz commented Jan 29, 2026

Copy link
Copy Markdown
Contributor

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 adding fgVNBasedIntrinsicExpansionForCall_StringComparison that vectorizes cases as shown in #84525:

private static readonly string s_prefix = "/api/data";

...
str.StartsWith(s_prefix, StringComparison.Ordinal)

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.

Copilot AI review requested due to automatic review settings January 29, 2026 15:01
@github-actions github-actions Bot added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Jan 29, 2026
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Jan 29, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 StartsWith calls with frozen strings
  • Marks StartsWith as a special intrinsic even when import-time vectorization fails, allowing later VN-based optimization
  • Extends the special intrinsic framework to handle NI_System_String_StartsWith alongside the existing ReadUtf8 expansion

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

Comment thread src/coreclr/jit/helperexpansion.cpp Outdated
JITDUMP("StringComparison: string exceeds max buffer size\n")
return false;
}

Copilot AI Jan 29, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
if (strObjOffset != 0)
{
JITDUMP("StringComparison: unexpected string object offset %d\n", (int)strObjOffset)
return false;
}

Copilot uses AI. Check for mistakes.
Comment thread src/coreclr/jit/helperexpansion.cpp Outdated
Comment on lines +1978 to +2002
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;
}
}

Copilot AI Jan 29, 2026

Copy link

Choose a reason for hiding this comment

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

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; }.

Copilot uses AI. Check for mistakes.
@EgorBo

EgorBo commented Jan 29, 2026

Copy link
Copy Markdown
Member

I think the pattern in question is already handled by #121985 ?

@reedz

reedz commented Jan 30, 2026

Copy link
Copy Markdown
Contributor Author

I think the pattern in question is already handled by #121985 ?

@EgorBo perhaps we could craft some benchmarks to verify ? IIUC #121985 doesn't handle the case when one of the operands is runtime value.

@EgorBo

EgorBo commented Feb 2, 2026

Copy link
Copy Markdown
Member

I think the pattern in question is already handled by #121985 ?

IIUC #121985 doesn't handle the case when one of the operands is runtime value.

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 StartsWith is inlined into SpanHelpers.SequenceEqual so for the constant length (static readonly string is folded into a frozen object too) we properly unroll it in Lower, just a little bit less efficient.

This logic effectively duplicates importervectorization.cpp which is fine if we actually delete importervectorization.cpp and move it here. Unfortunately, it seems to be focusing only on StartsWith and assume it will never inline.

Do you have a benchmark for this?

@reedz

reedz commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

@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);
}

@EgorBo

EgorBo commented Feb 20, 2026

Copy link
Copy Markdown
Member

The PR doesn't handle the same case for Span.
I'm just curious, is it a some kind of AI tool driven contribution?

{
return nullptr;
}
if (!info.compCompHnd->getObjectContent(objHnd, (uint8_t*)str, (int)(cnsLength * sizeof(char16_t)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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);
};

@EgorBo EgorBo Feb 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

@JulieLeeMSFT

Copy link
Copy Markdown
Member

@reedz, please address the reviews.

@JulieLeeMSFT JulieLeeMSFT added the needs-author-action An issue or pull request that requires more info or actions from the author. label Mar 16, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked no-recent-activity because it has not had any activity for 14 days. It will be closed if no further activity occurs within 14 more days. Any new comment (by anyone, not necessarily the author) will remove no-recent-activity.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

This pull request will now be closed since it had been marked no-recent-activity but received no further activity in the past 14 days. It is still possible to reopen or comment on the pull request, but please note that it will be locked if it remains inactive for another 30 days.

@github-actions github-actions Bot locked and limited conversation to collaborators May 14, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI community-contribution Indicates that the PR has been added by a community member needs-author-action An issue or pull request that requires more info or actions from the author. no-recent-activity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fold PathString aspnet pattern

4 participants