Skip to content

Commit 0b686a4

Browse files
Refactor for performance improvements
1 parent f330acb commit 0b686a4

7 files changed

Lines changed: 151 additions & 81 deletions

File tree

benchmarks/Benchmarks.csproj

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@
1414

1515
<ItemGroup>
1616
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
17-
<PackageReference Include="HL7-V2" Version="3.7.3" Condition="!$(DefineConstants.Contains('LOCAL_CODE'))" />
17+
<PackageReference Include="HL7-V2" Version="$(HL7V2Version)" Condition="'$(HL7V2Version)' != ''" />
1818
</ItemGroup>
1919

2020
<ItemGroup>
21-
<!-- only include a direct code ref if flag set, use nuget otherwise -->
22-
<ProjectReference Include="..\src\HL7-V2.csproj" Condition="$(DefineConstants.Contains('LOCAL_CODE'))" />
21+
<ProjectReference Include="..\src\HL7-V2.csproj" Condition="'$(HL7V2Version)' == ''" />
2322
</ItemGroup>
2423

25-
2624
</Project>

benchmarks/ParseMessageBench.cs

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,16 @@ namespace Benchmarks
1818
[HideColumns("BuildConfiguration", "Error", "StdDev", "RatioSD")]
1919
public class ParseMessageBench
2020
{
21-
22-
/*
23-
| Method | Job | Runtime | NuGetReferences | Mean | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
24-
|------------------------- |------------- |------------------- |-------------------- |---------:|------:|--------:|-------:|----------:|------------:|
25-
| ParseMessageAndGetValues | Net4.8 Local | .NET Framework 4.8 | Default | 75.54 us | 1.59 | 31.7383 | 4.3945 | 195.49 KB | 1.08 |
26-
| ParseMessageAndGetValues | Net4.8 Nuget | .NET Framework 4.8 | HL7-V2 2.39 | 99.49 us | 2.10 | 40.5273 | 6.1035 | 249.25 KB | 1.38 |
27-
| ParseMessageAndGetValues | Net8 Local | .NET 8.0 | Default | 33.67 us | 0.71 | 8.3008 | 1.3428 | 127.68 KB | 0.71 |
28-
| ParseMessageAndGetValues | Net8 Nuget | .NET 8.0 | HL7-V2 2.39 | 47.40 us | 1.00 | 11.7798 | 1.9531 | 180.7 KB | 1.00 |
29-
*/
30-
3121
internal static readonly string _sampleMessage = File.ReadAllText("Sample-Orm.txt");
3222

3323
private class Config : ManualConfig
3424
{
3525
public Config()
3626
{
37-
var baseJob = Job.ShortRun;
38-
39-
AddJob(baseJob.WithMsBuildArguments("/p:PackageReference=HL7-V2,Version=2.39").WithRuntime(CoreRuntime.Core80).WithId("Net8 Nuget").AsBaseline());
40-
AddJob(baseJob.WithMsBuildArguments("/p:PackageReference=HL7-V2,Version=2.39").WithRuntime(ClrRuntime.Net48).WithId("Net4.8 Nuget"));
27+
var baseJob = Job.MediumRun;
4128

42-
// custom config to include/exclude nuget reference or target project reference locally
43-
AddJob(baseJob.WithRuntime(ClrRuntime.Net48).WithCustomBuildConfiguration("LOCAL_CODE").WithId("Net4.8 Local"));
44-
AddJob(baseJob.WithRuntime(CoreRuntime.Core80).WithCustomBuildConfiguration("LOCAL_CODE").WithId("Net8 Local"));
29+
AddJob(baseJob.WithMsBuildArguments("/p:HL7V2Version=3.7.2").WithId("NuGet 3.7.2").AsBaseline());
30+
AddJob(baseJob.WithId("Local"));
4531
}
4632
}
4733

@@ -51,12 +37,33 @@ public void ParseMessageAndGetValues()
5137
var msg = new Message(_sampleMessage);
5238
msg.ParseMessage(true);
5339

54-
var ack = msg.GetACK(true);
55-
string sendingApp = ack.GetValue("MSH.3");
56-
string sendingFacility = ack.GetValue("MSH.4");
57-
string receivingApp = ack.GetValue("MSH.5");
58-
string receivingFacility = ack.GetValue("MSH.6");
59-
string messageType = ack.GetValue("MSH.9");
40+
// 2-component paths: SegmentRegex + FieldSegmentRegex (×2 each)
41+
_ = msg.GetValue("MSH.3");
42+
_ = msg.GetValue("MSH.4");
43+
_ = msg.GetValue("MSH.5");
44+
_ = msg.GetValue("MSH.9");
45+
_ = msg.GetValue("PID.7");
46+
_ = msg.GetValue("PID.8");
47+
_ = msg.GetValue("ORC.1");
48+
_ = msg.GetValue("ORC.2");
49+
_ = msg.GetValue("OBR.2");
50+
51+
// 3-component paths: SegmentRegex + FieldSegmentRegex + OtherRegex (×3 each)
52+
_ = msg.GetValue("PID.5.1");
53+
_ = msg.GetValue("PID.5.2");
54+
_ = msg.GetValue("PID.5.3");
55+
_ = msg.GetValue("PID.11.3");
56+
_ = msg.GetValue("PID.11.4");
57+
_ = msg.GetValue("PID.11.5");
58+
_ = msg.GetValue("ORC.12.1");
59+
_ = msg.GetValue("ORC.12.2");
60+
_ = msg.GetValue("OBR.4.1");
61+
_ = msg.GetValue("OBR.4.2");
62+
_ = msg.GetValue("OBR.4.3");
63+
64+
// Segment occurrence index: exercises the [n] capture group in SegmentRegex
65+
_ = msg.GetValue("NTE[1].3");
66+
_ = msg.GetValue("NTE[2].3");
6067
}
6168
}
6269
}

benchmarks/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,35 @@
11
Set of general benchmarks for testing both different SDK versions, and also looking for improvements
2+
3+
## How to run
4+
5+
```bash
6+
dotnet run -c Release -f net10.0
7+
```
8+
9+
To run a specific benchmark:
10+
11+
```bash
12+
dotnet run -c Release -f net10.0 -- --filter '*Serialize*'
13+
dotnet run -c Release -f net10.0 -- --filter '*ParseMessage*'
14+
```
15+
16+
## Baseline comparison
17+
18+
Each benchmark suite defines two jobs:
19+
20+
- **NuGet 3.7.2** (baseline) — runs against the published NuGet package
21+
- **Local** — runs against the local source tree
22+
23+
This is controlled by the `HL7V2Version` MSBuild property in `Benchmarks.csproj`:
24+
- When `HL7V2Version` is set (e.g. `/p:HL7V2Version=3.7.2`), the project references the specified NuGet package.
25+
- When it is not set (the default for the Local job), the project references the local source directly via `ProjectReference`.
26+
27+
## What is being measured
28+
29+
### SerializeBench
30+
31+
Measures `Encode()` throughput during serialization. The main improvement is a fast-path in `HL7Encoding.Encode()`: `IndexOfAny` checks upfront whether the value contains any characters that need escaping, skipping the full scan when none are found. The special-character array is also cached and only rebuilt when a delimiter property changes (dirty flag).
32+
33+
### ParseMessageBench
34+
35+
Measures parse + field access throughput. Exercises both 2-component (`Segment.Field`) and 3-component (`Segment.Field.Component`) query paths, plus segment occurrence indexing (`NTE[1].3`). On .NET 7+, query validation uses `[GeneratedRegex]`-backed methods instead of repeated `Regex.Matches` calls with inline pattern strings.

benchmarks/SerializeBench.cs

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,39 +14,18 @@ namespace Benchmarks
1414
[HideColumns("BuildConfiguration", "Error", "StdDev", "RatioSD")]
1515
public class SerializeBench
1616
{
17-
/*
18-
| Method | Job | Runtime | Arguments | Mean | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
19-
|----------------------- |------------- |------------------- |--------------------------------------- |---------:|------:|-------:|-------:|----------:|------------:|
20-
| SerializeMessage_Sync | Net4.8 Local | .NET Framework 4.8 | Default | 48.86 us | 2.57 | 3.2959 | - | 20.4 KB | 0.46 |
21-
| SerializeMessage_Sync | Net4.8 Nuget | .NET Framework 4.8 | /p:PackageReference=HL7-V2,Version=3.7 | 53.45 us | 2.81 | 8.6670 | 0.0610 | 53.38 KB | 1.20 |
22-
| SerializeMessage_Sync | Net8 Local | .NET 8.0 | Default | 12.76 us | 0.67 | 2.8229 | 0.0305 | 17.3 KB | 0.39 |
23-
| SerializeMessage_Sync | Net8 Nuget | .NET 8.0 | /p:PackageReference=HL7-V2,Version=3.7 | 19.18 us | 1.01 | 7.2632 | 0.0610 | 44.65 KB | 1.00 |
24-
| | | | | | | | | | |
25-
| SerializeMessage_Async | Net4.8 Local | .NET Framework 4.8 | Default | 61.34 us | 2.51 | 3.5400 | 0.0610 | 21.84 KB | 0.47 |
26-
| SerializeMessage_Async | Net4.8 Nuget | .NET Framework 4.8 | /p:PackageReference=HL7-V2,Version=3.7 | 77.40 us | 3.16 | 8.9111 | 0.1221 | 54.83 KB | 1.19 |
27-
| SerializeMessage_Async | Net8 Local | .NET 8.0 | Default | 15.39 us | 0.63 | 3.0518 | 0.0610 | 18.73 KB | 0.41 |
28-
| SerializeMessage_Async | Net8 Nuget | .NET 8.0 | /p:PackageReference=HL7-V2,Version=3.7 | 24.56 us | 1.00 | 7.5073 | 0.1526 | 46.08 KB | 1.00 |
29-
*/
17+
3018
private static readonly string _sampleMessage = File.ReadAllText("Sample-Orm.txt");
3119
private Message _msg;
3220

3321
private class Config : ManualConfig
3422
{
3523
public Config()
3624
{
37-
var baseJob = Job.ShortRun;
38-
39-
AddJob(baseJob.WithMsBuildArguments("/p:PackageReference=HL7-V2,Version=3.7")
40-
.WithRuntime(CoreRuntime.Core80)
41-
.WithId("Net8 Nuget").AsBaseline());
42-
AddJob(baseJob.WithMsBuildArguments("/p:PackageReference=HL7-V2,Version=3.7")
43-
.WithRuntime(ClrRuntime.Net48)
44-
.WithId("Net4.8 Nuget"));
45-
46-
AddJob(baseJob.WithRuntime(ClrRuntime.Net48).WithCustomBuildConfiguration("LOCAL_CODE")
47-
.WithId("Net4.8 Local"));
48-
AddJob(baseJob.WithRuntime(CoreRuntime.Core80).WithCustomBuildConfiguration("LOCAL_CODE")
49-
.WithId("Net8 Local"));
25+
var baseJob = Job.MediumRun;
26+
27+
AddJob(baseJob.WithMsBuildArguments("/p:HL7V2Version=3.7.2").WithId("NuGet 3.7.2").AsBaseline());
28+
AddJob(baseJob.WithId("Local"));
5029
}
5130
}
5231

src/HL7Encoding.cs

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,54 @@ namespace Efferent.HL7.V2
1111
/// </summary>
1212
public class HL7Encoding
1313
{
14+
private bool _invalidChars = true;
15+
16+
private char _fieldDelimiter = '|'; // \F\
17+
private char _componentDelimiter = '^'; // \S\
18+
private char _repeatDelimiter = '~'; // \R\
19+
private char _escapeCharacter = '\\'; // \E\
20+
private char _subComponentDelimiter = '&'; // \T\
21+
1422
/// <summary>
1523
/// Gets or sets the field delimiter character used to separate fields within a segment.
1624
/// </summary>
17-
public char FieldDelimiter { get; set; } = '|'; // \F\
25+
public char FieldDelimiter
26+
{
27+
get => _fieldDelimiter;
28+
set { _fieldDelimiter = value; _invalidChars = true; }
29+
}
1830
/// <summary>
1931
/// Gets or sets the component delimiter character used to separate components within a field.
2032
/// </summary>
21-
public char ComponentDelimiter { get; set; } = '^'; // \S\
33+
public char ComponentDelimiter
34+
{
35+
get => _componentDelimiter;
36+
set { _componentDelimiter = value; _invalidChars = true; }
37+
}
2238
/// <summary>
2339
/// Gets or sets the repeat delimiter character used to separate repeated fields or components.
2440
/// </summary>
25-
public char RepeatDelimiter { get; set; } = '~'; // \R\
41+
public char RepeatDelimiter
42+
{
43+
get => _repeatDelimiter;
44+
set { _repeatDelimiter = value; _invalidChars = true; }
45+
}
2646
/// <summary>
2747
/// Gets or sets the escape character used to denote escaped sequences within HL7 content.
2848
/// </summary>
29-
public char EscapeCharacter { get; set; } = '\\'; // \E\
49+
public char EscapeCharacter
50+
{
51+
get => _escapeCharacter;
52+
set { _escapeCharacter = value; _invalidChars = true; }
53+
}
3054
/// <summary>
3155
/// Gets or sets the subcomponent delimiter character used to separate subcomponents within a component.
3256
/// </summary>
33-
public char SubComponentDelimiter { get; set; } = '&'; // \T\
57+
public char SubComponentDelimiter
58+
{
59+
get => _subComponentDelimiter;
60+
set { _subComponentDelimiter = value; _invalidChars = true; }
61+
}
3462
/// <summary>
3563
/// Gets or sets the segment delimiter string used to separate segments within an HL7 message.
3664
/// </summary>
@@ -47,10 +75,14 @@ public class HL7Encoding
4775
private static readonly string[] _segmentDelimiters = { "\r\n", "\n\r", "\r", "\n" };
4876
private char[] _charsThatMightNeedEncoding;
4977

50-
public HL7Encoding()
78+
private char[] CharsThatMightNeedEncoding()
5179
{
52-
// set the defaults
53-
_charsThatMightNeedEncoding = new[] { '<', '\r', '\n', FieldDelimiter, ComponentDelimiter, RepeatDelimiter, EscapeCharacter, SubComponentDelimiter };
80+
if (_invalidChars)
81+
{
82+
_charsThatMightNeedEncoding = new[] { '<', '\r', '\n', FieldDelimiter, ComponentDelimiter, RepeatDelimiter, EscapeCharacter, SubComponentDelimiter };
83+
_invalidChars = false;
84+
}
85+
return _charsThatMightNeedEncoding;
5486
}
5587

5688
/// <summary>
@@ -73,7 +105,6 @@ public void EvaluateDelimiters(string delimiters)
73105
this.EscapeCharacter = delimiters[3];
74106
this.SubComponentDelimiter = delimiters[4];
75107
}
76-
_charsThatMightNeedEncoding = new[] { '<', '\r', '\n', FieldDelimiter, ComponentDelimiter, RepeatDelimiter, EscapeCharacter, SubComponentDelimiter };
77108
}
78109

79110
/// <summary>
@@ -109,9 +140,8 @@ public string Encode(string val)
109140
return val;
110141

111142
// If there's nothing that needs encoding, just return the value as-is
112-
// Disabled as it was breaking the CustomDelimiterTest() test method
113-
// if (val.IndexOfAny(_charsThatMightNeedEncoding) < 0)
114-
// return val;
143+
if (val.IndexOfAny(CharsThatMightNeedEncoding()) < 0)
144+
return val;
115145

116146
var sb = new StringBuilder();
117147

src/Message.cs

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,6 @@ public class Message
2626

2727
private static readonly char[] _queryDelimiter = ['.'];
2828

29-
private const string segmentRegex = @"^([A-Z][A-Z][A-Z1-9])([\(\[]([0-9]+)[\)\]]){0,1}$";
30-
private const string fieldRegex = @"^([0-9]+)([\(\[]([0-9]+)[\)\]]){0,1}$";
31-
private const string otherRegEx = @"^[1-9]([0-9]{1,2})?$";
32-
3329
public Message()
3430
{
3531
}
@@ -232,7 +228,7 @@ public string GetValue(string strValueFormat)
232228

233229
if (isValid)
234230
{
235-
var matches = Regex.Matches(allComponents[0], segmentRegex);
231+
var matches = MessageHelper.SegmentRegex().Matches(allComponents[0]);
236232

237233
if (matches.Count < 1)
238234
throw new HL7Exception("Request format is not valid: " + strValueFormat);
@@ -744,7 +740,7 @@ private Message createAckMessage(string code, bool isNack, string errMsg, bool b
744740
private static Field getField(Segment segment, string index)
745741
{
746742
int repetition = 0;
747-
var matches = Regex.Matches(index, fieldRegex);
743+
var matches = MessageHelper.FieldRegex().Matches(index);
748744

749745
if (matches.Count < 1)
750746
throw new HL7Exception("Invalid field index");
@@ -776,7 +772,7 @@ private static Field getField(Segment segment, string index)
776772
/// <returns>A boolean indicating whether the field has repetitions</returns>
777773
private static int getFieldRepetitions(Segment segment, string index)
778774
{
779-
var matches = Regex.Matches(index, fieldRegex);
775+
var matches = MessageHelper.FieldRegex().Matches(index);
780776

781777
if (matches.Count < 1)
782778
return 0;
@@ -829,7 +825,7 @@ private bool validateMessage()
829825
continue;
830826

831827
string segmentName = strSegment.Substring(0, 3);
832-
bool isValidSegmentName = Regex.IsMatch(segmentName, segmentRegex);
828+
bool isValidSegmentName = MessageHelper.SegmentRegex().IsMatch(segmentName);
833829

834830
if (!isValidSegmentName)
835831
throw new HL7Exception("Invalid segment name found: " + strSegment, HL7Exception.BadMessage);
@@ -944,13 +940,13 @@ private static bool validateValueFormat(string[] allComponents)
944940

945941
if (allComponents.Length > 0)
946942
{
947-
if (Regex.IsMatch(allComponents[0], segmentRegex))
943+
if (MessageHelper.SegmentRegex().IsMatch(allComponents[0]))
948944
{
949945
for (int i = 1; i < allComponents.Length; i++)
950946
{
951-
if (i == 1 && Regex.IsMatch(allComponents[i], fieldRegex))
947+
if (i == 1 && MessageHelper.FieldRegex().IsMatch(allComponents[i]))
952948
isValid = true;
953-
else if (i > 1 && Regex.IsMatch(allComponents[i], otherRegEx))
949+
else if (i > 1 && MessageHelper.OtherRegex().IsMatch(allComponents[i]))
954950
isValid = true;
955951
else
956952
return false;

0 commit comments

Comments
 (0)