Skip to content

Performance Optimizations

Calvindd2f edited this page Jan 14, 2026 · 1 revision

Performance Optimizations

Relevant source files

The following files were used as context for generating this wiki page:

Purpose and Scope

This document details the specific optimization techniques implemented in OfficeScrubNative.dll that achieve 10-50x performance improvements over the original VBScript implementation. These optimizations focus on memory efficiency, CPU utilization, and I/O throughput through modern .NET patterns.

For general architecture of the native layer, see Native Library Overview. For implementation details of individual helper classes, see Helper Classes. For P/Invoke declarations and Win32 API usage, see P/Invoke and Win32 APIs.


Optimization Categories

The native layer implements five primary optimization strategies, each targeting different performance bottlenecks:

graph TD
    Native["OfficeScrubNative.dll<br/>Performance Layer"]
    
    subgraph "Memory Optimizations"
        Span["Span&lt;char&gt; Stack Allocation<br/>Zero heap pressure<br/>3-5x faster GUID ops"]
        Static["Static Caching<br/>DecodeTable<br/>GetHiveKey results"]
    end
    
    subgraph "Algorithm Optimizations"
        HashSet["HashSet Collections<br/>O(1) lookups<br/>ValidSkus/SpecialProducts"]
        StringComp["StringComparison<br/>.OrdinalIgnoreCase<br/>2x faster comparisons"]
    end
    
    subgraph "Concurrency Optimizations"
        Parallel["Parallel Processing<br/>Task.Run<br/>Parallel.ForEach"]
    end
    
    subgraph "Resource Management"
        Dispose["Proper Disposal<br/>Process objects<br/>Registry keys"]
    end
    
    Native --> Span
    Native --> Static
    Native --> HashSet
    Native --> StringComp
    Native --> Parallel
    Native --> Dispose
    
    Span --> GuidHelper["GuidHelper<br/>GetExpandedGuid()<br/>GetCompressedGuid()<br/>GetDecodedGuid()"]
    
    HashSet --> Orchestrator["OfficeScrubOrchestrator<br/>IsInScope()<br/>ValidSkus<br/>SpecialProducts"]
    
    StringComp --> Orchestrator2["OfficeScrubOrchestrator<br/>IsC2RPath()"]
    
    Parallel --> ProcessHelper["ProcessHelper<br/>TerminateProcesses()"]
    
    Dispose --> ProcessHelper2["ProcessHelper<br/>IsProcessRunning()"]
Loading

Sources: OfficeScrubC2R-Native.cs:1-1408, docs/OPTIMIZATION_ANALYSIS.md:1-349, docs/CHANGELOG.md:44-102


Span Stack Allocation

Overview

The most impactful optimization replaces StringBuilder and string.Substring() operations with stack-allocated Span<char> buffers in GUID manipulation methods. This eliminates heap allocations entirely for operations executed thousands of times during cleanup.

Implementation

The GuidHelper class implements three GUID conversion methods using span-based operations:

Method Purpose Original Allocations Optimized Allocations
GetExpandedGuid() Compressed → Standard GUID 4 (StringBuilder + 3 substrings) 0 (stack only)
GetCompressedGuid() Standard → Compressed GUID 3 (StringBuilder + 2 substrings) 0 (stack only)
GetDecodedGuid() Encoded → Standard GUID 2 (StringBuilder + format string) 0 (stack only)

Before: Heap Allocations

OfficeScrubC2R-Native.cs:174-214 shows the original implementation using StringBuilder:

// Original: 4 heap allocations per call
var sb = new StringBuilder(38);              // Allocation 1: StringBuilder
sb.Append('{');
sb.Append(Reverse(compressedGuid.Substring(0, 8)));  // Allocation 2 & 3: substring + reversed string
sb.Append('-');
sb.Append(Reverse(compressedGuid.Substring(8, 4)));  // Allocation 4 & 5: substring + reversed string
// ... more allocations

After: Stack Allocations

docs/OfficeScrubC2R-Native-Optimized-Real.cs:188-225 shows the optimized implementation:

// Optimized: 0 heap allocations
Span<char> result = stackalloc char[38];     // Stack allocation (no GC)
result[0] = '{';
result[9] = '-';
result[14] = '-';
result[19] = '-';
result[24] = '-';
result[37] = '}';

ReverseToSpan(compressedGuid.AsSpan(0, 8), result.Slice(1, 8));  // No allocations
ReverseToSpan(compressedGuid.AsSpan(8, 4), result.Slice(10, 4)); // No allocations

Memory Layout Comparison

graph LR
    subgraph "Original Implementation"
        HeapSB["Heap: StringBuilder<br/>38 bytes"]
        HeapSub1["Heap: Substring 1<br/>8 bytes"]
        HeapRev1["Heap: Reversed 1<br/>8 bytes"]
        HeapSub2["Heap: Substring 2<br/>4 bytes"]
        HeapRev2["Heap: Reversed 2<br/>4 bytes"]
        
        GC1["Garbage Collector<br/>Must track all objects"]
        
        HeapSB --> GC1
        HeapSub1 --> GC1
        HeapRev1 --> GC1
        HeapSub2 --> GC1
        HeapRev2 --> GC1
    end
    
    subgraph "Optimized Implementation"
        Stack["Stack: Span&lt;char&gt;<br/>38 bytes<br/>Deallocated automatically"]
        NoGC["No GC Involvement<br/>Zero tracking overhead"]
        
        Stack --> NoGC
    end
Loading

Performance Impact

From docs/OPTIMIZATION_ANALYSIS.md:268-276:

Metric Original Optimized Improvement
Execution Time (10,000 calls) 12.4ms 3.8ms 3.3x faster
Memory Allocations 4-5 per call 0 per call 100% reduction
GC Pressure ~800KB garbage 0 bytes Zero GC pressure

Sources: OfficeScrubC2R-Native.cs:172-328, docs/OfficeScrubC2R-Native-Optimized-Real.cs:173-332, docs/OPTIMIZATION_ANALYSIS.md:66-82


Static Caching

DecodeTable Optimization

The GetDecodedGuid() method uses a 128-byte lookup table to decode base85-encoded GUIDs. The original implementation allocated this table on every method call.

Before: Per-Call Allocation

OfficeScrubC2R-Native.cs:256-274 allocated the table each time:

public static bool GetDecodedGuid(string encodedGuid, out string decodedGuid)
{
    // Allocates 128 bytes on every call
    var table = new byte[]
    {
        0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,
        // ... 112 more bytes
    };
    
    int ascii = encodedGuid[i];
    int chr = table[ascii];  // Use table
}

After: Static Field

docs/OfficeScrubC2R-Native-Optimized-Real.cs:175-185 caches the table as a static field:

public static class GuidHelper
{
    // Allocated once at class initialization
    private static readonly byte[] DecodeTable = new byte[128]
    {
        0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,
        // ... 112 more bytes
    };
    
    public static bool GetDecodedGuid(string encodedGuid, out string decodedGuid)
    {
        int chr = DecodeTable[ascii];  // Use cached table
    }
}

Registry Hive Key Caching

The RegistryHelper class frequently converts RegistryHiveType enum values to RegistryKey objects through GetHiveKey(). While the optimization of caching these results provides negligible performance benefit (5ns savings on 5ms operations), the code uses a switch expression for clarity.

docs/OfficeScrubC2R-Native-Optimized-Real.cs:545-552 implements the modern pattern:

private RegistryKey GetHiveKey(RegistryHiveType hive) => hive switch
{
    RegistryHiveType.ClassesRoot => Registry.ClassesRoot,
    RegistryHiveType.CurrentUser => Registry.CurrentUser,
    RegistryHiveType.LocalMachine => Registry.LocalMachine,
    RegistryHiveType.Users => Registry.Users,
    _ => throw new ArgumentException("Invalid hive type")
};

Sources: OfficeScrubC2R-Native.cs:255-320, docs/OfficeScrubC2R-Native-Optimized-Real.cs:175-185, docs/OPTIMIZATION_ANALYSIS.md:83-98


HashSet Collection Optimization

ValidSkus and SpecialProducts

The OfficeScrubOrchestrator.IsInScope() method validates Office product codes against known SKU patterns. Converting validation arrays to HashSet<string> provides O(1) lookup complexity.

Original Array Implementation

OfficeScrubC2R-Native.cs:1392-1403 used array search:

public bool IsInScope(string productCode)
{
    var sku = upper.Substring(10, 4);
    var validSkus = new[] { "007E", "008F", "008C", "24E1", "237A", "00DD" };
    
    // O(n) linear search through array
    return validSkus.Contains(sku) ||
           upper == "{6C1ADE97-24E1-4AE4-AEDD-86D3A209CE60}" ||
           upper == "{9520DDEB-237A-41DB-AA20-F2EF2360DCEB}" ||
           upper == "{9AC08E99-230B-47E8-9721-4577B7F124EA}";
}

Optimized HashSet Implementation

docs/OfficeScrubC2R-Native-Optimized-Real.cs:1318-1381 uses static HashSets:

public class OfficeScrubOrchestrator
{
    // Static collections initialized once
    private static readonly HashSet<string> ValidSkus = new HashSet<string>
    {
        "007E", "008F", "008C", "24E1", "237A", "00DD"
    };

    private static readonly HashSet<string> SpecialProducts = new HashSet<string>
    {
        "{6C1ADE97-24E1-4AE4-AEDD-86D3A209CE60}",
        "{9520DDEB-237A-41DB-AA20-F2EF2360DCEB}",
        "{9AC08E99-230B-47E8-9721-4577B7F124EA}"
    };
    
    public bool IsInScope(string productCode)
    {
        // O(1) hash lookup
        if (SpecialProducts.Contains(upper))
            return true;
        
        var sku = upper.Substring(10, 4);
        return ValidSkus.Contains(sku);  // O(1) hash lookup
    }
}

Performance Characteristics

graph TD
    subgraph "Array Search - O(n)"
        Array["string[] validSkus<br/>6 elements"]
        Search1["Compare element 0"]
        Search2["Compare element 1"]
        Search3["Compare element 2"]
        Search4["Compare element 3"]
        Search5["Compare element 4"]
        Search6["Compare element 5"]
        
        Array --> Search1
        Search1 --> Search2
        Search2 --> Search3
        Search3 --> Search4
        Search4 --> Search5
        Search5 --> Search6
    end
    
    subgraph "HashSet Lookup - O(1)"
        HashSet["HashSet&lt;string&gt; ValidSkus<br/>6 elements"]
        Hash["Compute hash"]
        Lookup["Direct bucket access"]
        Compare["Single comparison"]
        
        HashSet --> Hash
        Hash --> Lookup
        Lookup --> Compare
    end
Loading

Note: With only 6 items, the performance difference is negligible (microseconds), but the pattern is correct for larger collections.

Sources: OfficeScrubC2R-Native.cs:1381-1404, docs/OfficeScrubC2R-Native-Optimized-Real.cs:1318-1383, docs/OPTIMIZATION_ANALYSIS.md:185-217


String Comparison Optimization

StringComparison.OrdinalIgnoreCase

The original code used ToLower() or ToUpperInvariant() for case-insensitive comparisons, allocating temporary lowercase/uppercase strings. The optimized version uses StringComparison.OrdinalIgnoreCase to avoid allocations.

Code Entity Mapping

graph LR
    subgraph "OfficeScrubOrchestrator"
        IsC2RPath["IsC2RPath(string path)"]
        Contains["path.Contains(pattern, ...)"]
    end
    
    subgraph "OfficeConstants"
        Patterns["C2R_PATTERNS<br/>string[] array"]
        Pattern1["ROOT\\OFFICE1"]
        Pattern2["Microsoft Office\\Root\\"]
        Pattern3["microsoft shared\\ClickToRun"]
        
        Patterns --> Pattern1
        Patterns --> Pattern2
        Patterns --> Pattern3
    end
    
    IsC2RPath --> Contains
    Contains --> Patterns
    
    subgraph "Before: Allocations"
        ToLower["path.ToLower()<br/>pattern.ToLower()"]
        TempString1["Temporary string 1"]
        TempString2["Temporary string 2"]
        
        ToLower --> TempString1
        ToLower --> TempString2
    end
    
    subgraph "After: No Allocations"
        OrdinalIgnoreCase["StringComparison<br/>.OrdinalIgnoreCase"]
        DirectCompare["Direct comparison<br/>No allocations"]
        
        OrdinalIgnoreCase --> DirectCompare
    end
Loading

Implementation

docs/OfficeScrubC2R-Native-Optimized-Real.cs:1352-1359:

public bool IsC2RPath(string path)
{
    if (string.IsNullOrEmpty(path))
        return false;

    // No allocation - directly compares without case conversion
    return OfficeConstants.C2R_PATTERNS.Any(pattern =>
        path.Contains(pattern, StringComparison.OrdinalIgnoreCase));
}

Performance Measurement

From docs/OPTIMIZATION_ANALYSIS.md:288-294:

Operation ToLower() Method StringComparison Improvement
1M comparisons 847ms 423ms 2x faster
Memory allocations 2MB temporary strings 0 bytes 100% reduction

Sources: OfficeScrubC2R-Native.cs:1372-1379, docs/OfficeScrubC2R-Native-Optimized-Real.cs:1352-1359, docs/OPTIMIZATION_ANALYSIS.md:99-114


Parallel Processing

Process Termination

The ProcessHelper.TerminateProcesses() method terminates multiple Office processes concurrently using Task.Run() for parallel execution.

Parallel Execution Pattern

graph TD
    Start["TerminateProcesses(processNames[])"]
    
    subgraph "Sequential Enumeration"
        Enum["foreach processName"]
        GetProcs["Process.GetProcessesByName()"]
        CreateTasks["Create Task for each process"]
    end
    
    subgraph "Parallel Execution"
        Task1["Task.Run()<br/>Process 1<br/>winword.exe"]
        Task2["Task.Run()<br/>Process 2<br/>excel.exe"]
        Task3["Task.Run()<br/>Process 3<br/>powerpnt.exe"]
        Task4["Task.Run()<br/>Process 4<br/>outlook.exe"]
        
        Kill1["process.Kill()"]
        Kill2["process.Kill()"]
        Kill3["process.Kill()"]
        Kill4["process.Kill()"]
        
        Wait1["WaitForExit(5000)"]
        Wait2["WaitForExit(5000)"]
        Wait3["WaitForExit(5000)"]
        Wait4["WaitForExit(5000)"]
        
        Task1 --> Kill1
        Task2 --> Kill2
        Task3 --> Kill3
        Task4 --> Kill4
        
        Kill1 --> Wait1
        Kill2 --> Wait2
        Kill3 --> Wait3
        Kill4 --> Wait4
    end
    
    WaitAll["Task.WaitAll(tasks, timeout)"]
    Return["Return terminated PIDs"]
    
    Start --> Enum
    Enum --> GetProcs
    GetProcs --> CreateTasks
    
    CreateTasks --> Task1
    CreateTasks --> Task2
    CreateTasks --> Task3
    CreateTasks --> Task4
    
    Wait1 --> WaitAll
    Wait2 --> WaitAll
    Wait3 --> WaitAll
    Wait4 --> WaitAll
    
    WaitAll --> Return
Loading

Implementation

OfficeScrubC2R-Native.cs:775-814 and docs/OfficeScrubC2R-Native-Optimized-Real.cs:747-789:

public List<int> TerminateProcesses(string[] processNames, int timeoutMs = 5000)
{
    var terminatedPids = new List<int>();
    var tasks = new List<Task>();

    foreach (var processName in processNames)
    {
        var processes = Process.GetProcessesByName(Path.GetFileNameWithoutExtension(processName));

        foreach (var process in processes)
        {
            var pid = process.Id;
            
            // Create parallel task for each process
            tasks.Add(Task.Run(() =>
            {
                try
                {
                    if (!process.HasExited)
                    {
                        process.Kill();
                        if (process.WaitForExit(timeoutMs))
                        {
                            lock (terminatedPids)  // Thread-safe list update
                            {
                                terminatedPids.Add(pid);
                            }
                        }
                    }
                }
                finally
                {
                    process.Dispose();  // Proper resource cleanup
                }
            }));
        }
    }

    // Wait for all parallel tasks with extended timeout
    Task.WaitAll(tasks.ToArray(), timeoutMs * 2);
    return terminatedPids;
}

Benefits

Aspect Sequential Parallel Improvement
4 processes 20 seconds (4×5s) 5 seconds 4x faster
CPU utilization 25% (1 core) 100% (4 cores) 4x utilization
Responsiveness Blocking Non-blocking User can continue

Sources: OfficeScrubC2R-Native.cs:774-814, docs/OfficeScrubC2R-Native-Optimized-Real.cs:746-789


Resource Management

Process Disposal

The optimized code ensures all Process objects are properly disposed to prevent resource leaks. The original code sometimes left processes undisposed if exceptions occurred.

Critical Fix in IsProcessRunning()

docs/OfficeScrubC2R-Native-Optimized-Real.cs:820-834:

public bool IsProcessRunning(string processName)
{
    try
    {
        var processes = Process.GetProcessesByName(Path.GetFileNameWithoutExtension(processName));
        var hasRunning = processes.Length > 0;
        
        // CRITICAL: Dispose all process objects before returning
        foreach (var p in processes)
            p.Dispose();
            
        return hasRunning;
    }
    catch
    {
        return false;
    }
}

Before: Process handles leaked if exception thrown during array enumeration.
After: All processes disposed in all code paths.

Registry Key Disposal

The RegistryHelper uses C# 8.0 using declarations for automatic disposal:

docs/OfficeScrubC2R-Native-Optimized-Real.cs:424-431:

private void AddKeysFromPath(RegistryKey hiveKey, string subKey, HashSet<string> keys)
{
    try
    {
        // C# 8.0 using declaration - automatically disposed at scope exit
        using var key = hiveKey.OpenSubKey(subKey, false);
        if (key != null)
        {
            foreach (var name in key.GetSubKeyNames())
                keys.Add(name);
        }
    }
    catch { }
}

Sources: OfficeScrubC2R-Native.cs:844-857, docs/OfficeScrubC2R-Native-Optimized-Real.cs:820-834, docs/OPTIMIZATION_ANALYSIS.md:164-181


Performance Measurement Summary

Real-World Impact

Based on typical Office uninstall scenario with 1000 registry keys, 5000 files, and 10 processes:

graph LR
    subgraph "Operation Breakdown"
        GUID["GUID Operations<br/>1000 calls"]
        Registry["Registry Enumeration<br/>1000 keys"]
        Files["File Deletion<br/>5000 files"]
        Processes["Process Termination<br/>10 processes"]
        Strings["String Comparisons<br/>10000 ops"]
    end
    
    subgraph "Original Performance"
        GUID_Old["12ms"]
        Reg_Old["2500ms"]
        File_Old["8000ms"]
        Proc_Old["50ms<br/>sequential"]
        Str_Old["8ms"]
        Total_Old["Total: 10,570ms<br/>~10.6 seconds"]
    end
    
    subgraph "Optimized Performance"
        GUID_New["4ms<br/>3x faster"]
        Reg_New["2500ms<br/>no change"]
        File_New["8000ms<br/>no change"]
        Proc_New["13ms<br/>4x faster"]
        Str_New["4ms<br/>2x faster"]
        Total_New["Total: 10,521ms<br/>~10.5 seconds"]
    end
    
    GUID --> GUID_Old
    GUID --> GUID_New
    
    Registry --> Reg_Old
    Registry --> Reg_New
    
    Files --> File_Old
    Files --> File_New
    
    Processes --> Proc_Old
    Processes --> Proc_New
    
    Strings --> Str_Old
    Strings --> Str_New
    
    GUID_Old --> Total_Old
    Reg_Old --> Total_Old
    File_Old --> Total_Old
    Proc_Old --> Total_Old
    Str_Old --> Total_Old
    
    GUID_New --> Total_New
    Reg_New --> Total_New
    File_New --> Total_New
    Proc_New --> Total_New
    Str_New --> Total_New
Loading

Optimization Impact by Component

Component Method Original Optimized Improvement Frequency Total Savings
GuidHelper GetExpandedGuid() 12µs 4µs 3.0x 1000 calls 8ms
GuidHelper GetDecodedGuid() 15µs 5µs 3.0x 500 calls 5ms
ProcessHelper TerminateProcesses() 50ms 13ms 3.8x 1 call 37ms
OfficeScrubOrchestrator String comparisons 0.8µs 0.4µs 2.0x 10000 calls 4ms
RegistryHelper I/O-bound operations 2500ms 2500ms 1.0x N/A 0ms
FileHelper I/O-bound operations 8000ms 8000ms 1.0x N/A 0ms

Key Insight: CPU-bound operations (GUID, string, process) show 2-4x improvements. I/O-bound operations (registry, files) are unchanged because the bottleneck is disk/system speed, not code efficiency.

Sources: docs/OPTIMIZATION_ANALYSIS.md:247-297, docs/CHANGELOG.md:46-102


Invalid Optimizations (Rejected)

HashSet for C2R_PATTERNS

docs/OPTIMIZATION_ANALYSIS.md:203-217 explains why converting C2R_PATTERNS to HashSet provides no benefit:

// This does NOT benefit from HashSet because .Any() performs sequential scan
OfficeConstants.C2R_PATTERNS.Any(pattern => 
    path.Contains(pattern, StringComparison.OrdinalIgnoreCase))

// HashSet only helps for direct membership tests:
if (OfficeConstants.C2R_PATTERNS.Contains(specificPattern))  // O(1) vs O(n)

Verdict: Kept as array. HashSet adds overhead without benefit for .Any() usage.

Cached Registry Hive Keys

docs/OPTIMIZATION_ANALYSIS.md:219-236 analyzes the claimed "2-3x faster registry operations":

Savings from caching GetHiveKey():
  - Switch statement execution: ~5 nanoseconds
  
Actual registry operation cost:
  - RegOpenKeyEx() Win32 API: ~5,000,000 nanoseconds (5ms)
  
Performance improvement:
  - 5ns / 5,000,000ns = 0.0001% (unmeasurable)

Verdict: Switch expression used for code clarity, but caching provides no measurable benefit.

Removing Classes

docs/OPTIMIZATION_ANALYSIS.md:239-246 identifies the most critical issue:

  • ShellHelper, TypeLibHelper, ServiceHelper classes were removed in a "optimized" version
  • This is deletion, not optimization
  • Breaks 40% of functionality
  • Real optimization preserves 100% functionality while improving performance

Verdict: All classes retained. Optimization must never remove functionality.

Sources: docs/OPTIMIZATION_ANALYSIS.md:203-246


Optimization Guidelines

When to Optimize

graph TD
    Start["Identify Performance Issue"]
    Profile["Profile with Measurement<br/>Find actual bottleneck"]
    
    CPU{"CPU-bound<br/>operation?"}
    IO{"I/O-bound<br/>operation?"}
    
    OptCPU["Apply CPU optimizations:<br/>- Span&lt;char&gt;<br/>- Static caching<br/>- Parallel processing"]
    
    OptIO["I/O optimizations:<br/>- Batch operations<br/>- Async I/O<br/>- Native APIs"]
    
    NoOpt["No optimization needed<br/>I/O dominates runtime"]
    
    Measure["Measure improvement<br/>Before vs After"]
    
    Decision{"Measurable<br/>improvement?"}
    
    Keep["Keep optimization"]
    Revert["Revert to original<br/>Complexity not justified"]
    
    Start --> Profile
    Profile --> CPU
    Profile --> IO
    
    CPU --> OptCPU
    IO --> OptIO
    IO --> NoOpt
    
    OptCPU --> Measure
    OptIO --> Measure
    
    Measure --> Decision
    Decision -->|Yes >= 10%| Keep
    Decision -->|No < 10%| Revert
Loading

Best Practices

  1. Measure first: Profile before optimizing to find real bottlenecks
  2. Preserve functionality: Never delete features in the name of optimization
  3. Document assumptions: Explain why optimization is valid for this use case
  4. Measure results: Quantify improvement with benchmarks
  5. Consider maintainability: Optimization should not significantly increase complexity

Sources: docs/OPTIMIZATION_ANALYSIS.md:316-328

Clone this wiki locally