Skip to content

Reduce string allocations#1639

Draft
paulomorgado wants to merge 1 commit into
sipsorcery-org:masterfrom
paulomorgado:string-comparison
Draft

Reduce string allocations#1639
paulomorgado wants to merge 1 commit into
sipsorcery-org:masterfrom
paulomorgado:string-comparison

Conversation

@paulomorgado
Copy link
Copy Markdown
Contributor

@paulomorgado paulomorgado commented May 26, 2026

Summary

This PR reduces temporary string generation across SIP, RTSP, SDP, RTP, WebRTC, and related example/test code paths. The changes replace allocation-heavy parsing and comparison patterns with ordinal string comparisons, ReadOnlySpan<char> slicing, destination-span split buffers, and more efficient serialization patterns.

The intent is to preserve existing behavior while reducing avoidable allocations in protocol parsing and message formatting hot paths.

Part of: #1434

Why

Protocol parsing in this library runs frequently and often on network-facing hot paths. Several existing patterns created unnecessary intermediate strings or arrays while parsing or comparing protocol text:

  • ToUpper() / ToLower() before comparison
  • Trim() and Substring() for short-lived parser slices
  • string.Split(...) where only a few fields are needed
  • Regex usage for fixed-format parsing or header normalization
  • Repeated += string concatenation while serializing headers and SDP blocks
  • Culture-sensitive or allocation-producing string comparisons where ordinal protocol comparisons are intended

These patterns increase GC pressure and make hot-path parsing more expensive than necessary. They can also obscure the intended comparison semantics, especially for protocol tokens where ordinal comparisons are the correct behavior.

What changed

String comparisons

Replaced case-normalizing comparisons such as:

  • value.ToUpper() == ...
  • value.ToLower() == ...
  • value.ToUpperInvariant().StartsWith(...)
  • value.ToLowerInvariant().Contains(...)

with explicit ordinal comparisons, primarily:

  • StringComparison.Ordinal
  • StringComparison.OrdinalIgnoreCase

This avoids temporary strings and makes protocol comparison semantics explicit.

Span-based parsing

Reworked many parser paths to operate on ReadOnlySpan<char> instead of repeatedly materializing strings.

This includes parsing in:

  • SIP headers, requests, responses, URIs, parameters, and authorization digests
  • RTSP headers, messages, requests, responses, and URLs
  • SDP descriptions, media announcements, RTP maps, FMTP attributes, crypto attributes, and connection information
  • Selected RTP, WebRTC, ICE, system, example, and test code paths

The parser logic now generally slices spans and only converts back to string at API/storage boundaries where a string is actually required.

Split allocation reduction

Replaced several string.Split(...) usages with span-based splitting.

For fixed-shape parsing, the code now uses destination-span split buffers such as:

Span<Range> ranges = stackalloc Range[3];
var count = source.Split(ranges, '=');

This preserves the original “exactly N parts” behavior without allocating a string array or intermediate string segments.

Where appropriate, split options are used to preserve intended trimming/removal behavior without extra Trim() calls, such as SDP line splitting with remove-empty and trim semantics.

Trim and substring reduction

Replaced parser-local Trim() and Substring() usage with span slicing and span trimming.

This avoids creating temporary strings for values that are only needed for comparison, branching, or short-lived parsing decisions.

Strings are still materialized when they are stored in public object properties, dictionaries, collections, or returned through existing APIs.

Regex removal in fixed-format hot paths

Removed regex usage from several parser paths where the format is fixed and can be parsed directly.

Notable examples include:

  • SIP/RTSP header unfolding
  • SDP fixed-format field parsing
  • Digest/header parsing patterns

These paths now use direct span scanning/slicing instead of regex match objects and replacement strings.

Serialization allocation reduction

Reworked repeated string concatenation in message serialization paths.

Changes include:

  • Using StringBuilder for repeated line/block construction
  • Using interpolated strings for fixed-format output
  • Avoiding long chains of + concatenation where interpolation is clearer and lets the compiler choose the appropriate lowering

This primarily affects SIP, RTSP, and SDP serialization code.

Compatibility support

Added Polyfill support and updated LangVersion to 14.0 so newer APIs and compiler features can be used consistently across the library’s target frameworks.

This makes span/range/split-based parsing available in cross-targeted code while keeping the implementation aligned with modern C# syntax and compiler behavior.

How

The implementation follows a few consistent patterns:

  1. Use ordinal comparisons instead of allocating normalized-case strings.
  2. Parse protocol text as spans and delay string creation until necessary.
  3. Use Span<Range> / stackalloc split buffers for small fixed-field parsing.
  4. Use split options where they preserve existing trim/remove-empty behavior.
  5. Replace fixed-format regex parsing with direct span-based parsing.
  6. Use StringBuilder for repeated serialization and interpolated strings for fixed-format output.
  7. Keep externally visible behavior and string-backed object models unchanged.
  8. Use Polyfill plus C# 14 compiler support to make newer APIs and language features available consistently across target frameworks.

Validation

Validated with:

  • Release build of src\SIPSorcery\SIPSorcery.csproj
  • Focused parser/unit test runs covering the changed SIP/RTSP/SDP parsing paths
  • Whitespace/diff validation before commit

Some broader test runs may still be affected by unrelated networking/environment issues.

@sipsorcery
Copy link
Copy Markdown
Member

This PR introduces some readbility issues in places. Replacing case statements with large if/else blocks is not really desireable.

In other cases if the code is going to be changed it should go further and make use of new built in methods.

For example, changing:

m_sipCallDescriptor.AuthUsername == null || m_sipCallDescriptor.AuthUsername.Trim().Length <= 0

to:

m_sipCallDescriptor.AuthUsername == null || m_sipCallDescriptor.AuthUsername.AsSpan().Trim().Length <= 0

would be better as:

string.IsNullOrWhiteSpace(m_sipCallDescriptor.AuthUsername)

@paulomorgado
Copy link
Copy Markdown
Contributor Author

This PR introduces some readbility issues in places. Replacing case statements with large if/else blocks is not really desireable.

At the moment, C#/.NET is not able to use switch for performant ordinal ignore case matching in a performant way.

I'll try to come up with something that makes this more concise.

In other cases if the code is going to be changed it should go further and make use of new built in methods.

For example, changing:

m_sipCallDescriptor.AuthUsername == null || m_sipCallDescriptor.AuthUsername.Trim().Length <= 0

to:

m_sipCallDescriptor.AuthUsername == null || m_sipCallDescriptor.AuthUsername.AsSpan().Trim().Length <= 0

would be better as:

string.IsNullOrWhiteSpace(m_sipCallDescriptor.AuthUsername)

OOPS! Missed those ones. I'll do another pass.

@paulomorgado
Copy link
Copy Markdown
Contributor Author

@sipsorcery,

I reverted back to switch, and used string.IsNullOrWhiteSpace(...).

I have a little devil screaming in the back of my mind to create EqualsOic extension methods to use StringComparison.OrdinalIgnoreCase and make the code less dense.

{
// Attempt to execute the current command.
switch (command.ToLower())
switch (command)
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 case statement could be kept clean and readable by just changing the switch to: switch(command.ToLowerInvariant())

if (args?.Length > 0)
{
switch(args[0].ToLower())
if (string.Equals(args[0], FFMPEG_VP8_CODEC, StringComparison.OrdinalIgnoreCase) ||
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 original swtich could be left in place, and still get the benefit of the suggestion, by doing:

switch (args[0].ToLowerInvariant())
    {
        case FFMPEG_VP8_CODEC:
        case FFMPEG_VP9_CODEC:
...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

args[0].ToLowerInvariant() this may unnecessarily allocate a string,

_logger.LogInformation("ICE Candidate: " + message);

if (string.IsNullOrWhiteSpace(message) || message.Trim().ToLower() == SDP.END_ICE_CANDIDATES_ATTRIBUTE)
if (string.IsNullOrWhiteSpace(message) || message.AsSpan().Trim().Equals(SDP.END_ICE_CANDIDATES_ATTRIBUTE, StringComparison.OrdinalIgnoreCase))
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.

I think an extension method for this is justified (as per your main comment) given the frequency of use and the fact it's a bit of a mouthful.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I tried to optimize strings as much as possible in this PR.

I'm sure more pattern will be uncovered.

I'd like propose other optimizations but, as you mention, these can become large PRs

{
string trimmedExtension = extension.Trim().ToLower();
switch (trimmedExtension)
var trimmedExtension = extension.Trim();
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 case could be kept here as well by using the ToLowerInvariant() in the original swtitch.

@sipsorcery
Copy link
Copy Markdown
Member

I agree with the improvements in this PR but it's big and touches some very fundamental classes. Can it be split up even further. The string formatting on all the demo apps and tests are low risk and could be put in a separate PR. The changes to the core SIP classes are the biggest risk and they should be separated into their own PR.

@paulomorgado
Copy link
Copy Markdown
Contributor Author

@sipsorcery

I'm creating a PR for the changes under test and examples directories.

What is your plan after that?

@sipsorcery
Copy link
Copy Markdown
Member

@sipsorcery

I'm creating a PR for the changes under test and examples directories.

What is your plan after that?

Do you mean as far as merging? I don't see any issues merging the changes in the test and examples directories.

If one of the PR's was solely for string formatting and spacing that would aslo be straight forward to verify and merge.

The bigger changes, particulatly in the SIPSorcery/core directory, are the ones that will need the most thorough testing and I'd plan to test them with as many of the SIP examples as practicable.

@paulomorgado
Copy link
Copy Markdown
Contributor Author

@sipsorcery
I'm creating a PR for the changes under test and examples directories.
What is your plan after that?

Do you mean as far as merging? I don't see any issues merging the changes in the test and examples directories.

If one of the PR's was solely for string formatting and spacing that would aslo be straight forward to verify and merge.

The bigger changes, particulatly in the SIPSorcery/core directory, are the ones that will need the most thorough testing and I'd plan to test them with as many of the SIP examples as practicable.

Use ordinal comparisons and span-based parsing to avoid temporary string generation across SIP, RTSP, SDP, RTP, and WebRTC paths.

Replace allocation-heavy Trim, Substring, Split, regex, and string concatenation patterns where span slicing, destination-span split buffers, or StringBuilder preserve behavior with fewer intermediate strings.

Convert staged string.Format and fixed concatenation sites to interpolation, and
replace loop-based string concatenation with StringBuilder where strings are
built incrementally.

Avoid temporary string creation in SIP custom header prefix checks. Change
SIPHeader and RTSPHeader ToString implementations to override object.ToString()
so interpolating header instances preserves protocol serialization behavior.

Replace span-trim length checks with string.IsNullOrWhiteSpace where only
null/blank detection is needed.

Keep SDP media status parsing close to the original switch structure by using
guarded cases with ordinal ignore-case comparisons. Use discard patterns for
guarded switch cases where the matched value is not used.

Replace the long SIP/SDP test fixture with a raw string literal and
normalize it with ReplaceLineEndings(CRLF) so the protocol payload stays
stable across LF and CRLF checkouts.

Set the unit test project to C# 14 and import Polyfills so the call also
compiles for net462.

Convert long SIP and SDP test payloads from CRLF-heavy interpolated
strings to raw string literals normalized with ReplaceLineEndings.

Share the repeated integration INVITE payload through a helper and update
test projects to C# 14 so raw strings compile across target frameworks.
@paulomorgado paulomorgado marked this pull request as draft May 29, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants