Skip to content

Comments

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

Open
reedz wants to merge 2 commits intodotnet:mainfrom
reedz:feature/fold-path-string-pattern
Open

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

Conversation

@reedz
Copy link
Contributor

@reedz reedz commented Jan 29, 2026

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
Copy link
Contributor

Copilot AI left a comment

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

JITDUMP("StringComparison: string exceeds max buffer size\n")
return false;
}

Copy link

Copilot AI Jan 29, 2026

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 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;
}
}
Copy link

Copilot AI Jan 29, 2026

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
Copy link
Member

EgorBo commented Jan 29, 2026

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

@reedz
Copy link
Contributor Author

reedz commented Jan 30, 2026

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
Copy link
Member

EgorBo commented Feb 2, 2026

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
Copy link
Contributor Author

reedz commented Feb 8, 2026

@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
Copy link
Member

EgorBo commented Feb 20, 2026

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
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);
};
Copy link
Member

@EgorBo EgorBo Feb 20, 2026

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fold PathString aspnet pattern

2 participants