Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 204 additions & 1 deletion bench/PrivacyFilter.Net.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
using BenchmarkDotNet.Running;
using PrivacyFilterNet;

BenchmarkRunner.Run<PrivacyFilterBenchmarks>();
BenchmarkSwitcher.FromAssembly(typeof(PrivacyFilterBenchmarks).Assembly).Run(args);

[MemoryDiagnoser]
public class PrivacyFilterBenchmarks
Expand Down Expand Up @@ -34,3 +34,206 @@ public void Setup()
[Benchmark]
public PrivacyFilterResult LongText() => _filter!.Redact(_longInput);
}

[MemoryDiagnoser]
public class ManagedPostprocessingBenchmarks
{
private const int TokenCount = 4096;
private float[] _scores = null!;
private float[] _logProbabilities = null!;
private float[] _denseStartScores = null!;
private float[] _denseEndScores = null!;
private float[] _denseTransitionScores = null!;
private ViterbiDecoder _decoder = null!;
private int _classCount;

[GlobalSetup]
public void Setup()
{
LabelSpace labels = LabelSpace.Create(CreateClassNames());
_decoder = new ViterbiDecoder(labels, calibrationPath: null);
_classCount = labels.TokenClassNames.Length;
_scores = new float[TokenCount * _classCount];
_logProbabilities = new float[_scores.Length];
var random = new Random(42);
for (int index = 0; index < _scores.Length; index++)
{
_scores[index] = (random.NextSingle() * 20) - 10;
}

PrivacyFilter.LogSoftmax(
_scores,
_logProbabilities,
TokenCount,
_classCount,
destinationTokenOffset: 0);
CreateDenseScores(
_classCount,
out _denseStartScores,
out _denseEndScores,
out _denseTransitionScores);
}

[Benchmark]
public void LogSoftmax() =>
PrivacyFilter.LogSoftmax(
_scores,
_logProbabilities,
TokenCount,
_classCount,
destinationTokenOffset: 0);

[Benchmark(Baseline = true)]
public int[] ViterbiDense() =>
DecodeDense(
_logProbabilities,
TokenCount,
_classCount,
_denseStartScores,
_denseEndScores,
_denseTransitionScores);

[Benchmark]
public int[] ViterbiSparse() => _decoder.Decode(_logProbabilities, TokenCount);

[Benchmark]
public int[] ArgMax() => PrivacyFilter.ArgMax(_logProbabilities, TokenCount, _classCount);

private static int[] DecodeDense(
float[] emissions,
int tokenCount,
int classCount,
float[] startScores,
float[] endScores,
float[] transitionScores)
{
var previousScores = new float[classCount];
var nextScores = new float[classCount];
var backpointers = new int[(tokenCount - 1) * classCount];
for (int label = 0; label < classCount; label++)
{
previousScores[label] = emissions[label] + startScores[label];
}

for (int token = 1; token < tokenCount; token++)
{
int emissionOffset = token * classCount;
int backpointerOffset = (token - 1) * classCount;
for (int next = 0; next < classCount; next++)
{
float bestScore = float.NegativeInfinity;
int bestPrevious = 0;
for (int previous = 0; previous < classCount; previous++)
{
float score = previousScores[previous] +
transitionScores[(previous * classCount) + next];
if (score > bestScore)
{
bestScore = score;
bestPrevious = previous;
}
}

nextScores[next] = bestScore + emissions[emissionOffset + next];
backpointers[backpointerOffset + next] = bestPrevious;
}

(previousScores, nextScores) = (nextScores, previousScores);
}

int lastLabel = 0;
float bestFinalScore = float.NegativeInfinity;
for (int label = 0; label < classCount; label++)
{
float score = previousScores[label] + endScores[label];
if (score > bestFinalScore)
{
bestFinalScore = score;
lastLabel = label;
}
}

var path = new int[tokenCount];
path[^1] = lastLabel;
for (int token = tokenCount - 2; token >= 0; token--)
{
lastLabel = backpointers[(token * classCount) + lastLabel];
path[token] = lastLabel;
}

return path;
}

private static void CreateDenseScores(
int classCount,
out float[] startScores,
out float[] endScores,
out float[] transitionScores)
{
const float invalid = -1e9f;
startScores = Enumerable.Repeat(invalid, classCount).ToArray();
endScores = Enumerable.Repeat(invalid, classCount).ToArray();
transitionScores = Enumerable.Repeat(invalid, classCount * classCount).ToArray();
for (int previous = 0; previous < classCount; previous++)
{
char? previousTag = Boundary(previous);
if (previous == 0 || previousTag is 'B' or 'S')
{
startScores[previous] = 0;
}

if (previous == 0 || previousTag is 'E' or 'S')
{
endScores[previous] = 0;
}

for (int next = 0; next < classCount; next++)
{
if (IsValidTransition(previous, next))
{
transitionScores[(previous * classCount) + next] = 0;
}
}
}
}

private static bool IsValidTransition(int previous, int next)
{
char? previousTag = Boundary(previous);
char? nextTag = Boundary(next);
if (previous == 0 || previousTag is 'E' or 'S')
{
return next == 0 || nextTag is 'B' or 'S';
}

return SpanLabel(previous) == SpanLabel(next) && nextTag is 'I' or 'E';
}

private static char? Boundary(int label) => label == 0 ? null : "BIES"[(label - 1) % 4];

private static int SpanLabel(int label) => label == 0 ? 0 : ((label - 1) / 4) + 1;

private static string[] CreateClassNames()
{
var names = new List<string> { "O" };
foreach (string label in new[]
{
"account_number",
"private_address",
"private_date",
"private_email",
"private_person",
"private_phone",
"private_url",
"secret",
})
{
foreach (char boundary in "BIES")
{
names.Add($"{boundary}-{label}");
}
}

return names.ToArray();
}
}
28 changes: 28 additions & 0 deletions bench/results.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ Measured 2026-07-21 on Windows 11 with an Intel Core i7-13800H (14 physical,
| Short text | 356.6 ms | 10.82 ms | 16.6 KB |
| Long text (50 copies) | 2,048.4 ms | 22.09 ms | 644.85 KB |

## Managed postprocessing optimization

Measured 2026-07-22 with 4,096 tokens and 33 classes. The dense and sparse
Viterbi implementations used the same nontrivial score input.

| Workload | Mean | Managed allocation | Relative time |
| --- | ---: | ---: | ---: |
| Log-softmax removed from production | 1.093 ms | 0 B | - |
| Dense Viterbi baseline | 4.527 ms | 544.28 KB | 1.00x |
| Sparse Viterbi with pooled backpointers | 2.065 ms | 16.02 KB | 0.46x |

Sparse valid-predecessor tables reduced Viterbi time by 54% and managed
allocation by 97%. Decoding raw logits also removes the separate log-softmax
pass without changing argmax or Viterbi results.

The end-to-end comparison used 5 warmups and 10 measurement iterations for
each implementation:

| Workload | Before | After | Allocation before | Allocation after |
| --- | ---: | ---: | ---: | ---: |
| Short text | 234.4 ms | 232.6 ms | 16.69 KB | 7.93 KB |
| Long text (50 copies) | 1,048.7 ms | 1,078.4 ms | 644.85 KB | 222.09 KB |

End-to-end latency remained effectively model-bound: the short workload
improved by 0.8%, while the noisier long workload measured 2.8% slower with
overlapping confidence intervals. Managed allocation fell by 52% and 66%,
respectively.

## Same-model Python comparison

For an apples-to-apples model comparison, a Python harness ran the same
Expand Down
1 change: 1 addition & 0 deletions src/PrivacyFilter.Net/PrivacyFilter.Net.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="PrivacyFilter.Net.Benchmarks" />
<InternalsVisibleTo Include="PrivacyFilter.Net.Tests" />
</ItemGroup>

Expand Down
Loading