diff --git a/VERSION b/VERSION index d0c2b19c1e..050ffa7368 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.3.12 \ No newline at end of file +3.3.13 \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Actions.UnitTests/DiskSpd/DiskSpdMetricsParserTests.cs b/src/VirtualClient/VirtualClient.Actions.UnitTests/DiskSpd/DiskSpdMetricsParserTests.cs index a20b1f85dc..507b3a0c9c 100644 --- a/src/VirtualClient/VirtualClient.Actions.UnitTests/DiskSpd/DiskSpdMetricsParserTests.cs +++ b/src/VirtualClient/VirtualClient.Actions.UnitTests/DiskSpd/DiskSpdMetricsParserTests.cs @@ -330,5 +330,80 @@ public void DiskSpdParserVerifyForCoreCountGreaterThan64WhichAddsProcessorGroupi MetricAssert.Exists(metrics, "total latency 75th", 2.819, "ms"); MetricAssert.Exists(metrics, "total latency 90th", 7.472, "ms"); } + + [Test] + public void DiskSpdParserVerifyV220FormatOnMultiSocketSingleProcessorGroupSystem() + { + // Authentic DiskSpd 2.2 output captured from a 64-vCPU Azure VM (Standard_E64ds_v5: + // 2 sockets, 2 NUMA nodes, 1 processor group). Because DiskSpd emits each topology column + // (Socket/Node/Group/Core/Class) only when the system has more than one of that unit, this + // exactly-64-vCPU system produces the intermediate header "Socket | Node | Core | CPU" - + // it has the Socket and Node columns but NO Group column (64 vCPUs fit in one group). This + // is the customer's "exactly 64 vCPUs fails" case: it matches neither the full + // "Socket | Node | Group | Core | CPU" header nor the bare "Core | CPU" header that earlier + // fixes special-cased, so before NormalizeCpuTable it threw "The given key 'CPU' was not + // present in the dictionary" (the title was inserted mid-line, keying the section + // "Socket | Node | CPU"). + string results = File.ReadAllText(MockFixture.GetDirectory(typeof(DiskSpdMetricsParserTests), "Examples", "DiskSpd", "DiskSpdExample-WriteOnly-v2.2.0-MultiSocketSingleGroup.txt")); + var parser = new DiskSpdMetricsParser(results, "diskspd.exe -c64M -b4K -r4K -t4 -o4 -w100 -d5 -Suw -W2 -D -L -Rtext C:\\dskspd\\testfile.dat"); + + IList metrics = parser.Parse(); + + // cpu metrics - the Socket, Node and Core columns must all be stripped so rows are keyed by + // the CPU number (0..5). The User/Kernel values (e.g. cpu 1) prove the columns are not + // shifted after dropping three leading topology columns. + MetricAssert.Exists(metrics, "cpu usage 0", 1.25, "percentage"); + MetricAssert.Exists(metrics, "cpu usage 1", 3.12, "percentage"); + MetricAssert.Exists(metrics, "cpu usage 2", 0.62, "percentage"); + MetricAssert.Exists(metrics, "cpu usage 3", 0.94, "percentage"); + MetricAssert.Exists(metrics, "cpu usage average", 0.09, "percentage"); + MetricAssert.Exists(metrics, "cpu user 1", 0.31, "percentage"); + MetricAssert.Exists(metrics, "cpu kernel 1", 2.81, "percentage"); + MetricAssert.Exists(metrics, "cpu kernel average", 0.09, "percentage"); + + // Total IO + Write IO + latency must still parse end-to-end. + MetricAssert.Exists(metrics, "total bytes total", 69226496, "bytes"); + MetricAssert.Exists(metrics, "total throughput total", 13.18, "MiB/s"); + MetricAssert.Exists(metrics, "write iops total", 3374.97, "iops"); + MetricAssert.Exists(metrics, "write latency 50th", 4.738, "ms"); + MetricAssert.Exists(metrics, "total latency max", 46.704, "ms"); + } + + [Test] + public void DiskSpdParserVerifyV220FormatOnMultiNumaMultiProcessorGroupSystem() + { + // Authentic DiskSpd 2.2 output captured from a 96-vCPU Azure VM (Standard_D96as_v5: + // 1 socket, 2 NUMA nodes, 2 processor groups). This produces the intermediate header + // "Node | Group | Core | CPU" - it has the Node, Group and Core columns but no Socket + // column. The Node and Core columns must be dropped while the Group column is RETAINED, so + // that the group-relative CPU number is mapped to a unique processor id across the group + // boundary (group 1's CPU 0 becomes id 64 = 64*group + cpu). Without retaining Group, the + // group-relative CPU numbers (0,1,2,.. repeated per group) would collide. Before the + // dynamic fix this header threw "The given key 'CPU' was not present in the dictionary". + string results = File.ReadAllText(MockFixture.GetDirectory(typeof(DiskSpdMetricsParserTests), "Examples", "DiskSpd", "DiskSpdExample-WriteOnly-v2.2.0-MultiNumaMultiGroup.txt")); + var parser = new DiskSpdMetricsParser(results, "diskspd.exe -c64M -b4K -r4K -t4 -o4 -w100 -d5 -Suw -W2 -D -L -Rtext C:\\dskspd\\testfile.dat"); + + IList metrics = parser.Parse(); + + // Group 0 CPUs are keyed 0..3 (64*0 + cpu); the User/Kernel values prove the Node and Core + // columns were stripped without shifting the value columns. + MetricAssert.Exists(metrics, "cpu usage 0", 0.31, "percentage"); + MetricAssert.Exists(metrics, "cpu usage 1", 4.08, "percentage"); + MetricAssert.Exists(metrics, "cpu usage 2", 3.13, "percentage"); + MetricAssert.Exists(metrics, "cpu user 1", 1.57, "percentage"); + MetricAssert.Exists(metrics, "cpu kernel 1", 2.51, "percentage"); + + // Group 1 CPUs are offset to 64..67 (64*1 + cpu) so they do not collide with group 0. + MetricAssert.Exists(metrics, "cpu usage 64", 0, "percentage"); + MetricAssert.Exists(metrics, "cpu usage 65", 0, "percentage"); + MetricAssert.Exists(metrics, "cpu usage average", 0.72, "percentage"); + + // Total IO + Write IO + latency must still parse end-to-end. + MetricAssert.Exists(metrics, "total bytes total", 24952832, "bytes"); + MetricAssert.Exists(metrics, "total throughput total", 4.75, "MiB/s"); + MetricAssert.Exists(metrics, "write iops total", 1215.24, "iops"); + MetricAssert.Exists(metrics, "write latency 50th", 6.402, "ms"); + MetricAssert.Exists(metrics, "total latency max", 53.872, "ms"); + } } } \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Actions.UnitTests/Examples/DiskSpd/DiskSpdExample-WriteOnly-v2.2.0-MultiNumaMultiGroup.txt b/src/VirtualClient/VirtualClient.Actions.UnitTests/Examples/DiskSpd/DiskSpdExample-WriteOnly-v2.2.0-MultiNumaMultiGroup.txt new file mode 100644 index 0000000000..e9ceb54e88 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Actions.UnitTests/Examples/DiskSpd/DiskSpdExample-WriteOnly-v2.2.0-MultiNumaMultiGroup.txt @@ -0,0 +1,76 @@ + +Command Line: C:\dskspd\amd64\diskspd.exe -c64M -b4K -r4K -t4 -o4 -w100 -d5 -Suw -W2 -D -L -Rtext C:\dskspd\testfile.dat + +System information: + + computer name: alexwill-amd96 + start time: 2026/06/26 17:40:00 UTC + + cpu count: 96 + core count: 48 + group count: 2 + node count: 2 + socket count: 1 + heterogeneous cores: n + +Results for timespan 1: +******************************************************************************* + +actual test time: 5.00s +thread count: 4 + +Node | Group | Core | CPU | Usage | User | Kernel | Idle +------------------------------------------------------------------ + 0| 0| 0| 0| 0.31%| 0.00%| 0.31%| 99.69% + 0| 0| 0| 1| 4.08%| 1.57%| 2.51%| 95.92% + 0| 0| 1| 2| 3.13%| 1.88%| 1.25%| 96.87% + 0| 0| 1| 3| 1.57%| 0.63%| 0.94%| 98.43% + 1| 1| 0| 0| 0.00%| 0.00%| 0.00%| 100.00% + 1| 1| 0| 1| 0.00%| 0.00%| 0.00%| 100.00% + 1| 1| 1| 2| 0.00%| 0.00%| 0.00%| 100.00% + 1| 1| 1| 3| 0.00%| 0.00%| 0.00%| 100.00% +------------------------------------------------------------------ + avg.| 0.72%| 0.48%| 0.24%| 99.28% + +Total IO +thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | IopsStdDev | LatStdDev | file +------------------------------------------------------------------------------------------------------------------ + 0 | 12554240 | 3065 | 2.39 | 611.41 | 6.491 | 280.11 | 13.500 | C:\dskspd\testfile.dat (64MiB) + 1 | 12398592 | 3027 | 2.36 | 603.83 | 6.572 | 281.21 | 13.668 | C:\dskspd\testfile.dat (64MiB) +------------------------------------------------------------------------------------------------------------------ +total: 24952832 | 6092 | 4.75 | 1215.24 | 6.531 | 396.90 | 13.584 + +Read IO +thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | IopsStdDev | LatStdDev | file +------------------------------------------------------------------------------------------------------------------ + 0 | 0 | 0 | 0.00 | 0.00 | 0.000 | 0.00 | N/A | C:\dskspd\testfile.dat (64MiB) + 1 | 0 | 0 | 0.00 | 0.00 | 0.000 | 0.00 | N/A | C:\dskspd\testfile.dat (64MiB) +------------------------------------------------------------------------------------------------------------------ +total: 0 | 0 | 0.00 | 0.00 | 0.000 | 0.00 | N/A + +Write IO +thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | IopsStdDev | LatStdDev | file +------------------------------------------------------------------------------------------------------------------ + 0 | 12554240 | 3065 | 2.39 | 611.41 | 6.491 | 280.11 | 13.500 | C:\dskspd\testfile.dat (64MiB) + 1 | 12398592 | 3027 | 2.36 | 603.83 | 6.572 | 281.21 | 13.668 | C:\dskspd\testfile.dat (64MiB) +------------------------------------------------------------------------------------------------------------------ +total: 24952832 | 6092 | 4.75 | 1215.24 | 6.531 | 396.90 | 13.584 + +Total latency distribution: + %-ile | Read (ms) | Write (ms) | Total (ms) +---------------------------------------------- + min | N/A | 1.100 | 1.100 + 25th | N/A | 4.214 | 4.214 + 50th | N/A | 6.402 | 6.402 + 75th | N/A | 8.298 | 8.298 + 90th | N/A | 10.887 | 10.887 + 95th | N/A | 12.994 | 12.994 + 99th | N/A | 18.342 | 18.342 +3-nines | N/A | 44.981 | 44.981 +4-nines | N/A | 52.115 | 52.115 +5-nines | N/A | 53.872 | 53.872 +6-nines | N/A | 53.872 | 53.872 +7-nines | N/A | 53.872 | 53.872 +8-nines | N/A | 53.872 | 53.872 +9-nines | N/A | 53.872 | 53.872 + max | N/A | 53.872 | 53.872 diff --git a/src/VirtualClient/VirtualClient.Actions.UnitTests/Examples/DiskSpd/DiskSpdExample-WriteOnly-v2.2.0-MultiSocketSingleGroup.txt b/src/VirtualClient/VirtualClient.Actions.UnitTests/Examples/DiskSpd/DiskSpdExample-WriteOnly-v2.2.0-MultiSocketSingleGroup.txt new file mode 100644 index 0000000000..76186f44e8 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Actions.UnitTests/Examples/DiskSpd/DiskSpdExample-WriteOnly-v2.2.0-MultiSocketSingleGroup.txt @@ -0,0 +1,74 @@ + +Command Line: C:\dskspd\amd64\diskspd.exe -c64M -b4K -r4K -t4 -o4 -w100 -d5 -Suw -W2 -D -L -Rtext C:\dskspd\testfile.dat + +System information: + + computer name: alexwill-int64 + start time: 2026/06/26 17:55:00 UTC + + cpu count: 64 + core count: 32 + group count: 1 + node count: 2 + socket count: 2 + heterogeneous cores: n + +Results for timespan 1: +******************************************************************************* + +actual test time: 5.00s +thread count: 4 + +Socket | Node | Core | CPU | Usage | User | Kernel | Idle +------------------------------------------------------------------- + 0| 0| 0| 0| 1.25%| 0.00%| 1.25%| 98.75% + 0| 0| 0| 1| 3.12%| 0.31%| 2.81%| 96.88% + 0| 0| 1| 2| 0.62%| 0.00%| 0.62%| 99.38% + 0| 0| 1| 3| 0.94%| 0.00%| 0.94%| 99.06% + 0| 0| 2| 4| 0.00%| 0.00%| 0.00%| 100.00% + 0| 0| 2| 5| 0.00%| 0.00%| 0.00%| 100.00% +------------------------------------------------------------------- + avg.| 0.09%| 0.00%| 0.09%| 99.91% + +Total IO +thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | IopsStdDev | LatStdDev | file +------------------------------------------------------------------------------------------------------------------ + 0 | 17256448 | 4213 | 3.29 | 841.30 | 4.753 | 45.39 | 9.686 | C:\dskspd\testfile.dat (64MiB) + 1 | 17440768 | 4258 | 3.32 | 850.28 | 4.703 | 49.64 | 9.635 | C:\dskspd\testfile.dat (64MiB) +------------------------------------------------------------------------------------------------------------------ +total: 69226496 | 16901 | 13.18 | 3374.97 | 4.739 | 176.61 | 9.670 + +Read IO +thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | IopsStdDev | LatStdDev | file +------------------------------------------------------------------------------------------------------------------ + 0 | 0 | 0 | 0.00 | 0.00 | 0.000 | 0.00 | N/A | C:\dskspd\testfile.dat (64MiB) + 1 | 0 | 0 | 0.00 | 0.00 | 0.000 | 0.00 | N/A | C:\dskspd\testfile.dat (64MiB) +------------------------------------------------------------------------------------------------------------------ +total: 0 | 0 | 0.00 | 0.00 | 0.000 | 0.00 | N/A + +Write IO +thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | IopsStdDev | LatStdDev | file +------------------------------------------------------------------------------------------------------------------ + 0 | 17256448 | 4213 | 3.29 | 841.30 | 4.753 | 45.39 | 9.686 | C:\dskspd\testfile.dat (64MiB) + 1 | 17440768 | 4258 | 3.32 | 850.28 | 4.703 | 49.64 | 9.635 | C:\dskspd\testfile.dat (64MiB) +------------------------------------------------------------------------------------------------------------------ +total: 69226496 | 16901 | 13.18 | 3374.97 | 4.739 | 176.61 | 9.670 + +Total latency distribution: + %-ile | Read (ms) | Write (ms) | Total (ms) +---------------------------------------------- + min | N/A | 1.000 | 1.000 + 25th | N/A | 5.877 | 5.877 + 50th | N/A | 4.738 | 4.738 + 75th | N/A | 6.114 | 6.114 + 90th | N/A | 8.291 | 8.291 + 95th | N/A | 10.435 | 10.435 + 99th | N/A | 14.864 | 14.864 +3-nines | N/A | 38.880 | 38.880 +4-nines | N/A | 45.213 | 45.213 +5-nines | N/A | 46.704 | 46.704 +6-nines | N/A | 46.704 | 46.704 +7-nines | N/A | 46.704 | 46.704 +8-nines | N/A | 46.704 | 46.704 +9-nines | N/A | 46.704 | 46.704 + max | N/A | 46.704 | 46.704 diff --git a/src/VirtualClient/VirtualClient.Actions/DiskSpd/DiskSpdMetricsParser.cs b/src/VirtualClient/VirtualClient.Actions/DiskSpd/DiskSpdMetricsParser.cs index d0b311e362..f9cb52d5a3 100644 --- a/src/VirtualClient/VirtualClient.Actions/DiskSpd/DiskSpdMetricsParser.cs +++ b/src/VirtualClient/VirtualClient.Actions/DiskSpd/DiskSpdMetricsParser.cs @@ -33,6 +33,15 @@ public class DiskSpdMetricsParser : MetricsParser /// private static readonly Regex DashLineRegex = new Regex(@"(-){2,}(\s)*", RegexOptions.ExplicitCapture); + /// + /// Matches the DiskSpd CPU-utilization table header by its invariant trailing signature + /// ("CPU | Usage | User | Kernel | Idle"), regardless of how many leading topology + /// columns (Socket/Node/Group/Core/Class) DiskSpd prepended for the system's topology. + /// + private static readonly Regex CpuTableHeaderRegex = new Regex( + @"\bCPU\s*\|\s*Usage\s*\|\s*User\s*\|\s*Kernel\s*\|\s*Idle", + RegexOptions.ExplicitCapture); + private string commandLine; private ReadWriteMode readWriteMode; private List metrics; @@ -94,69 +103,25 @@ protected override void Preprocess() this.PreprocessedText = TextParsingExtensions.RemoveRows(this.RawText, DiskSpdMetricsParser.DashLineRegex); /* - * Giving the CPU table a title - * - * Convert: - * CPU | Usage | User | Kernel | Idle - * - * To: - * CPU - * CPU | Usage | User | Kernel | Idle - * - * Convert: - * Group | CPU | Usage | User | Kernel | Idle - * - * To: - * CPU - * Group | CPU | Usage | User | Kernel | Idle - */ - - /* - * DiskSpd v2.2.0 added Socket, Node, Core columns to the CPU table: + * DiskSpd prefixes the CPU-utilization table's "CPU" column with a dynamic, hierarchical + * set of topology columns. Each one is emitted only when the system has more than one of + * that unit, in this fixed order (see DiskSpd ResultParser.cpp _PrintCpuUtilization): * - * Socket | Node | Group | Core | CPU | Usage | User | Kernel | Idle + * [Socket |] [Node |] [Group |] [Core |] [Class |] CPU | Usage | User | Kernel | Idle * - * Normalize this to the existing Group | CPU format by: - * 1. Replacing the extended header so sectionizing produces a "CPU" section. - * 2. Stripping Socket, Node, Core from every data row. + * - Socket : > 1 socket + * - Node : > 1 NUMA node + * - Group : > 1 processor group (i.e. > 64 vCPUs) + * - Core : SMT / hyper-threading enabled + * - Class : heterogeneous (performance/efficiency) cores + * + * Earlier fixes special-cased two exact header strings ("Socket | Node | Group | Core | CPU" + * and bare "Core | CPU"), so any other combination - e.g. a 64-vCPU VM with 2 NUMA nodes but + * 1 group emitting "Node | Core | CPU" - was mis-titled and threw KeyNotFoundException('CPU'). + * NormalizeCpuTable handles every combination (including future columns) by keying off the + * invariant "CPU | Usage | ..." signature instead of hard-coded prefixes. */ - if (this.PreprocessedText.Contains("Socket | Node | Group | Core | CPU")) - { - this.PreprocessedText = this.PreprocessedText.Replace( - "Socket | Node | Group | Core | CPU", - $"CPU{Environment.NewLine}Group | CPU"); - - this.PreprocessedText = Regex.Replace( - this.PreprocessedText, - @"^\s*(\d+)\s*\|\s*(\d+)\s*\|\s*(\d+)\s*\|\s*(\d+)\s*\|\s*(\d+)\s*\|", - " $3| $5|", - RegexOptions.Multiline); - } - else if (this.PreprocessedText.Contains("Core | CPU")) - { - /* - * DiskSpd v2.2.0 on single-processor-group systems (<= 64 vCPUs) omits the - * Socket, Node and Group columns and emits just "Core | CPU": - * - * Core | CPU | Usage | User | Kernel | Idle - * - * Give the table the "CPU" title so it sectionizes under the "CPU" key. The - * redundant Core column is stripped from the section data later, in - * ParseCPUResult (see RemoveCoreColumn), once the CPU table is isolated from - * the IO tables whose rows also begin with two integer columns. - */ - this.PreprocessedText = this.PreprocessedText.Replace( - "Core | CPU", - $"CPU{Environment.NewLine}Core | CPU"); - } - else if (this.PreprocessedText.Contains("Group")) - { - this.PreprocessedText = this.PreprocessedText.Replace("Group", $"CPU{Environment.NewLine}Group"); - } - else - { - this.PreprocessedText = this.PreprocessedText.Replace("CPU", $"CPU{Environment.NewLine}CPU"); - } + this.NormalizeCpuTable(); /* * Replace total: to it's actual TableName "Latency" @@ -218,6 +183,108 @@ protected override void Preprocess() } } + /// + /// Normalizes the CPU-utilization table to the canonical "[Group |] CPU | Usage | ..." form + /// and gives it a "CPU" section title, independent of which leading topology columns + /// (Socket/Node/Group/Core/Class) DiskSpd emitted for the current system. The Group column, + /// when present (> 64 vCPUs), is retained so that ParseCPUResult can map the group-relative + /// CPU number to a unique processor id; all other leading columns are dropped. + /// + private void NormalizeCpuTable() + { + string text = this.PreprocessedText; + string newLine = text.Contains("\r\n") ? "\r\n" : "\n"; + + string[] lines = text.Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + lines[i] = lines[i].TrimEnd('\r'); + } + + int headerIndex = Array.FindIndex(lines, line => CpuTableHeaderRegex.IsMatch(line)); + if (headerIndex < 0) + { + // No CPU table found (unexpected for DiskSpd output); leave the text untouched. + return; + } + + string[] headerColumns = lines[headerIndex].Split('|'); + int cpuColumn = Array.FindIndex(headerColumns, column => column.Trim() == "CPU"); + int groupColumn = Array.FindIndex(headerColumns, column => column.Trim() == "Group"); + bool hasGroup = groupColumn >= 0; + + if (cpuColumn < 0) + { + return; + } + + List output = new List(lines.Length + 1); + bool tableComplete = false; + bool sawDataRow = false; + + for (int i = 0; i < lines.Length; i++) + { + if (tableComplete || i < headerIndex) + { + output.Add(lines[i]); + continue; + } + + if (i == headerIndex) + { + // "CPU" becomes the section title (consumed by Sectionize as the section key); + // the rebuilt header becomes the column-name row consumed by ConvertToDataTable. + output.Add("CPU"); + output.Add(BuildCpuRow(headerColumns, cpuColumn, groupColumn, hasGroup)); + continue; + } + + string line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) + { + // A blank line terminates the CPU table, but only once its data rows are consumed + // (DiskSpd may leave a blank line between the header and the first data row). + if (sawDataRow) + { + tableComplete = true; + } + + output.Add(line); + continue; + } + + string[] columns = line.Split('|'); + if (columns.Length == headerColumns.Length && int.TryParse(columns[cpuColumn].Trim(), out _)) + { + sawDataRow = true; + output.Add(BuildCpuRow(columns, cpuColumn, groupColumn, hasGroup)); + } + else + { + // The "avg." summary row carries no topology columns; keep it verbatim. + output.Add(line); + } + } + + this.PreprocessedText = string.Join(newLine, output); + } + + private static string BuildCpuRow(string[] columns, int cpuColumn, int groupColumn, bool hasGroup) + { + List kept = new List(); + if (hasGroup) + { + kept.Add(columns[groupColumn].Trim()); + } + + for (int i = cpuColumn; i < columns.Length; i++) + { + kept.Add(columns[i].Trim()); + } + + return string.Join(" | ", kept); + } + private void ParseCPUResult() { string sectionName = "CPU";