Skip to content

Conversation

@tehtelev
Copy link

@tehtelev tehtelev commented Jan 2, 2026

A more efficient FillPlaceHolder implementation has been proposed (you can replace the original with it if you prefer). The main problem is using Regex with multiple calls from different locations. This implementation handles all replacements in a single pass. This manipulation is much simpler and faster.

Also used in PR here.

Added an optimized version of FillPlaceHolder that processes all replacements in a single pass, improving performance for placeholder replacements.
@tehtelev tehtelev marked this pull request as ready for review January 2, 2026 16:26
@tehtelev tehtelev changed the title Implement optimized methods Implement optimized method Jan 2, 2026
@scgm0
Copy link

scgm0 commented Jan 5, 2026

I think using ReadOnlySpan<char> could further optimize performance, but detailed testing is needed to check for any potential issues.

public static string FillPlaceHolderOptimizedV2(string input, OrderedDictionary<string, string> searchReplace) {
		if (string.IsNullOrWhiteSpace(input) || !input.Contains('{') || searchReplace is not { Count: not 0 }) {
			return input;
		}

		var inputSpan = input.AsSpan();
		var sb = new StringBuilder(input.Length);

		var pos = 0;
		while (pos < inputSpan.Length) {
			var startIndex = inputSpan[pos..].IndexOf('{');
			if (startIndex < 0) {
				sb.Append(inputSpan[pos..]);
				break;
			}

			var startPos = startIndex + pos;
			var closeIndex = inputSpan[(startPos + 1)..].IndexOf('}');
			if (closeIndex < 0) {
				sb.Append(inputSpan[pos..]);
				break;
			}

			sb.Append(inputSpan[pos..startPos]);

			var closePos = startPos + 1 + closeIndex;
			var placeholder = inputSpan[(startPos + 1)..closePos];

			if (TryResolvePlaceholder(placeholder, searchReplace, out var value)) {
				sb.Append(value);
			} else {
				sb.Append(inputSpan[pos..(closePos + 1)]);
			}

			pos = closePos + 1;
		}

		return sb.ToString();
	}

	static private bool TryResolvePlaceholder(
		ReadOnlySpan<char> placeholder,
		OrderedDictionary<string, string> searchReplace,
		out string value) {
		foreach (var (key, str) in searchReplace) {
			var partStart = 0;
			for (var i = 0; i <= placeholder.Length; i++) {
				if (i != placeholder.Length && placeholder[i] != '|') {
					continue;
				}

				var part = placeholder[partStart..i];
				if (part.SequenceEqual(key)) {
					value = str;
					return true;
				}

				partStart = i + 1;
			}
		}

		value = null;
		return false;
	}
public static readonly OrderedDictionary<string, string> Dict = [
		new("User", "scgm"),
		new("name", "Bob"),
		new("id", "123456")
	];

	public const string Input = "Hello {user|name}, your id is {id}. Repeat: {user|name} {user|name} {user|name}.";

	static private void Main() {
		BenchmarkRunner.Run<PlaceholderBenchmark>();
		var output = FillPlaceHolder(Input, Dict);
		Console.WriteLine($"Original {output}");
		output = FillPlaceHolderOptimized(Input, Dict);
		Console.WriteLine($"SpanOptimized {output}");
		output = FillPlaceHolderOptimizedV2(Input, Dict);
		Console.WriteLine($"SpanOptimizedV2 {output}");
	}
BenchmarkDotNet v0.15.8, Linux Arch Linux
Intel Core i5-9300H CPU 2.40GHz (Max: 4.00GHz), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.122
  [Host]     : .NET 8.0.22 (8.0.22, 8.0.2225.52707), X64 RyuJIT x86-64-v3
  Job-YFEFPZ : .NET 8.0.22 (8.0.22, 8.0.2225.52707), X64 RyuJIT x86-64-v3

IterationCount=10  WarmupCount=3  

| Method          | Mean       | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|---------------- |-----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Original        | 5,256.0 ns | 272.77 ns | 162.32 ns |  1.00 |    0.04 | 0.2441 |    1024 B |        1.00 |
| SpanOptimized   |   952.0 ns |  46.79 ns |  24.47 ns |  0.18 |    0.01 | 0.4101 |    1720 B |        1.68 |
| SpanOptimizedV2 |   339.1 ns |  21.32 ns |  14.10 ns |  0.06 |    0.00 | 0.0858 |     360 B |        0.35 |

...
Original Hello Bob, your id is 123456. Repeat: Bob Bob Bob.
SpanOptimized Hello Bob, your id is 123456. Repeat: Bob Bob Bob.
SpanOptimizedV2 Hello Bob, your id is 123456. Repeat: Bob Bob Bob.

@tehtelev
Copy link
Author

tehtelev commented Jan 5, 2026

@scgm0
I tested your code on a mod build, meaning under real-world conditions. Everything works correctly, without errors. We get the following test results in dotTrace's Sampling mode.

  1. Original (FillPlaceHolder): 28255 ms;
  2. My version (FillPlaceHolderOptimized): 2598 ms;
  3. Your version (FillPlaceHolderOptimizedV2): 1117 ms.

@scgm0
Copy link

scgm0 commented Jan 5, 2026

@tehtelev Can you test this again?

public static string FillPlaceHolderOptimizedV3(string input, OrderedDictionary<string, string> searchReplace) {
		if (string.IsNullOrWhiteSpace(input) || !input.Contains('{') || searchReplace is not { Count: not 0 }) {
			return input;
		}

		var inputSpan = input.AsSpan();
		var sb = new StringBuilder(input.Length);

		var pos = 0;
		while (pos < inputSpan.Length) {
			var startIndex = inputSpan[pos..].IndexOf('{');
			if (startIndex < 0) {
				sb.Append(inputSpan[pos..]);
				break;
			}

			var startPos = startIndex + pos;
			var closeIndex = inputSpan[(startPos + 1)..].IndexOf('}');
			if (closeIndex < 0) {
				sb.Append(inputSpan[pos..]);
				break;
			}

			sb.Append(inputSpan[pos..startPos]);

			var closePos = startPos + 1 + closeIndex;
			var placeholder = inputSpan[(startPos + 1)..closePos];

			if (TryResolvePlaceholder(placeholder, searchReplace, out var value)) {
				sb.Append(value);
			} else {
				sb.Append(inputSpan[pos..(closePos + 1)]);
			}

			pos = closePos + 1;
		}

		return sb.ToString();
	}

@tehtelev
Copy link
Author

tehtelev commented Jan 5, 2026

@scgm0 Your version (FillPlaceHolderOptimizedV3): 1201 ms.

@tehtelev
Copy link
Author

tehtelev commented Jan 5, 2026

@scgm0
There are still some issues with your version 2 and version 3 code. For example, this item (without mods) is losing textures. And this isn't an isolated incident.
2026-01-05_13-45-59

@scgm0
Copy link

scgm0 commented Jan 5, 2026

@scgm0 There are still some issues with your version 2 and version 3 code. For example, this item (without mods) is losing textures. And this isn't an isolated incident. 2026-01-05_13-45-59

Can you identify under what data the results are problematic?

@tehtelev
Copy link
Author

tehtelev commented Jan 5, 2026

figurehead.json
I don't know why, but the material is not processed.

Can you identify under what data the results are problematic?

@scgm0
Copy link

scgm0 commented Jan 5, 2026

figurehead.json I don't know why, but the material is not processed.

Can you identify under what data the results are problematic?

Do you have time to debug?

public static string FillPlaceHolder(string input, OrderedDictionary<string, string> searchReplace) {
		var optimized = FillPlaceHolderOptimizedV2(input, searchReplace);
		foreach (var val in searchReplace) {
			input = FillPlaceHolder(input, val.Key, val.Value);
		}

		if (optimized != input) {
			// log(optimized, input, input, searchReplace) or debug
		}
		return input;
	}

@tehtelev
Copy link
Author

tehtelev commented Jan 5, 2026

@scgm0
image
example.txt

@scgm0
Copy link

scgm0 commented Jan 5, 2026

@tehtelev Previously, I accidentally wrote sb.Append(inputSpan[startPos..(closePos + 1)]); as sb.Append(inputSpan[pos..(closePos + 1)]);, and now it should be fine.

public static string FillPlaceHolderOptimizedV2(string input, OrderedDictionary<string, string> searchReplace) {
		if (string.IsNullOrWhiteSpace(input) || !input.Contains('{') || searchReplace is not { Count: not 0 }) {
			return input;
		}

		var inputSpan = input.AsSpan();
		var sb = new StringBuilder(input.Length);

		var pos = 0;
		while (pos < inputSpan.Length) {
			var startIndex = inputSpan[pos..].IndexOf('{');
			if (startIndex < 0) {
				sb.Append(inputSpan[pos..]);
				break;
			}

			var startPos = startIndex + pos;
			var closeIndex = inputSpan[(startPos + 1)..].IndexOf('}');
			if (closeIndex < 0) {
				sb.Append(inputSpan[pos..]);
				break;
			}

			sb.Append(inputSpan[pos..startPos]);

			var closePos = startPos + 1 + closeIndex;
			var placeholder = inputSpan[(startPos + 1)..closePos];

			if (TryResolvePlaceholder(placeholder, searchReplace, out var value)) {
				sb.Append(value);
			} else {
				sb.Append(inputSpan[startPos..(closePos + 1)]);
			}

			pos = closePos + 1;
		}

		return sb.ToString();
	}
图片

@tehtelev
Copy link
Author

tehtelev commented Jan 5, 2026

@scgm0 All tests passed successfully. Runtime: 1373 ms. ^_^

@scgm0
Copy link

scgm0 commented Jan 6, 2026

I feel a bit obsessed with this, but now TryResolvePlaceholder also has a faster version, it shouldn't cause any issues.

static private bool TryResolvePlaceholderV2(
		ReadOnlySpan<char> placeholder,
		OrderedDictionary<string, string> searchReplace,
		out string value) {
		Span<Range> parts = stackalloc Range[placeholder.Length / 2];
		var partCount = 0;

		var start = 0;
		for (var i = 0; i <= placeholder.Length; i++) {
			if (i < placeholder.Length && placeholder[i] != '|')
				continue;

			parts[partCount++] = start..i;
			start = i + 1;
		}

		foreach (var (key, val) in searchReplace) {
			var keySpan = key.AsSpan();
			foreach (var r in parts[..partCount]) {
				var part = placeholder[r];
				if (!part.SequenceEqual(keySpan)) {
					continue;
				}

				value = val;
				return true;
			}
		}

		value = null;
		return false;
	}
| Method          | Mean       | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|---------------- |-----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Original        | 8,316.4 ns | 189.48 ns | 112.76 ns |  1.00 |    0.02 | 0.4425 |    1881 B |        1.00 |
| SpanOptimized   |   453.3 ns |   6.31 ns |   3.76 ns |  0.05 |    0.00 | 0.2251 |     944 B |        0.50 |
| SpanOptimizedV2 |   245.0 ns |  13.63 ns |   8.11 ns |  0.03 |    0.00 | 0.0477 |     200 B |        0.11 |
| TryResolvePlaceholderV2 |   202.9 ns |   6.60 ns |   3.93 ns |  0.02 |    0.00 | 0.0477 |     200 B |        0.11 |

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants