วัดให้เป็นก่อน: ทำไม column Allocated เชื่อได้ และ column Mean เชื่อไม่ได้
คอร์สนี้จะใช้เวลาอีกเจ็ดบทข้างหน้าไล่ลดจำนวน byte ที่ hot path ของ domain Order จองบน heap และเครื่องมือหลักที่จะใช้คือ Span<char> แต่ benchmark ชุดแรกที่รันบนเครื่องที่ใช้เขียนคอร์สนี้ กลับบอกว่า version Span<char> ช้ากว่า string.Split อยู่ 14.6%
ถ้าอ่านแค่ column Mean ก็ต้องสรุปว่าเทคนิคทั้งคอร์สนี้ไร้ประโยชน์ ข้อสรุปนั้นผิด และบทนี้มีหน้าที่อธิบายว่ามันผิดตรงไหน
ก่อนจะอ้างตัวเลขตัวแรกได้ คอร์สนี้ต้องปกป้องวิธีวัดของตัวเองให้ได้ก่อน บทนี้จึงไม่ได้สอนเทคนิคเพิ่มความเร็วสักตัวเดียว มันสอนว่าตัวเลขแบบไหนใช้เป็นหลักฐานได้ และตัวเลขแบบไหนที่ reproduce ได้เป๊ะ แต่ยังผิดอยู่ดี
ทั้งคอร์สเดินบน hot path เส้นเดียวของ domain Order ที่คอร์ส #8–#11 สร้างไว้ คือ parse บรรทัดออร์เดอร์ → รวมยอด → พิมพ์ใบเสร็จ (repo ตัวอย่าง .NET กำลังจัดทำ) ทุก snippet ในบทนี้รันได้จริงบน .NET SDK 10 และทุกตัวเลขที่พิมพ์ออกมาคือค่าที่วัดได้จริง ไม่ใช่ค่าที่เขียนขึ้นเอง
บทนี้ตั้ง precedent ให้อีกเจ็ดบท ทุกตารางตัวเลขจะมี block ID กำกับ ทุกตัวเลขจะมาคู่กับ input ที่ผลิตมัน และทุกครั้งที่แตะ tiered compilation จะลงเลขทั้ง2 tier พร้อมป้าย
ไวยากรณ์ C# 11–14 ที่ใช้ในบทนี้ (collection expression = [], primary constructor บน class) คอร์สนี้ ใช้ โดยไม่หยุดอธิบาย ถ้าเจอของที่ไม่คุ้นให้เปิด C# 8 → 14: อะไรเปลี่ยนไปบ้าง ควบคู่ไป
benchmark ของคอร์สนี้เอง จัดอันดับ Span ไว้กลางตาราง
หัวข้อที่มีชื่อว่า “benchmark ของคอร์สนี้เอง จัดอันดับ Span ไว้กลางตาราง”ตาราง BenchmarkDotNet ของจริงรอบแรก ที่นาฬิกาบอกว่า Span แพ้ ส่วน column byte บอกว่า Span ชนะขาด
input คงที่ทั้งตาราง 1 operation คือการ parse บรรทัดเดิมซ้ำ 1,000 บรรทัด และบรรทัดนั้นคือ
ORD-10482,742,Confirmed,145000,2024-06-15T18:32:07code ที่วัด (ตัดเหลือ2 method ที่อยู่คนละขั้วของตาราง — version Substring อยู่ระหว่างกลาง ใช้ line.Substring ตัดทีละช่อง):
[MemoryDiagnoser]public class ParseBench{ private string[] _lines = [];
[GlobalSetup] public void Setup() { _lines = new string[1000]; for (int i = 0; i < 1000; i++) _lines[i] = "ORD-10482,742,Confirmed,145000,2024-06-15T18:32:07"; }
[Benchmark(Baseline = true)] public long Split() { long sum = 0; foreach (string line in _lines) { string[] parts = line.Split(','); sum += parts[0].Length; sum += int.Parse(parts[1], CultureInfo.InvariantCulture); if (parts[2] == "Confirmed") sum += 1; sum += long.Parse(parts[3], CultureInfo.InvariantCulture); if (DateTime.TryParseExact(parts[4], "yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime t)) sum += t.Hour; } return sum; }
[Benchmark] public long Span() { long sum = 0; foreach (string line in _lines) { ReadOnlySpan<char> rest = line; int c1 = rest.IndexOf(','); int c2 = rest.Slice(c1 + 1).IndexOf(',') + c1 + 1; int c3 = rest.Slice(c2 + 1).IndexOf(',') + c2 + 1; int c4 = rest.Slice(c3 + 1).IndexOf(',') + c3 + 1; sum += rest.Slice(0, c1).Length; sum += int.Parse(rest.Slice(c1 + 1, c2 - c1 - 1), CultureInfo.InvariantCulture); if (rest.Slice(c2 + 1, c3 - c2 - 1).SequenceEqual("Confirmed")) sum += 1; sum += long.Parse(rest.Slice(c3 + 1, c4 - c3 - 1), CultureInfo.InvariantCulture); if (DateTime.TryParseExact(rest.Slice(c4 + 1, rest.Length - c4 - 1), "yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime t)) sum += t.Hour; } return sum; }}ผลรอบที่ 1 คำต่อคำจากล็อก บนเครื่อง Intel Core i7-7700 3.60GHz (Kaby Lake) · 8 logical / 4 physical · Ubuntu 24.04.4 LTS (WSL2) · .NET SDK 10.0.302 / .NET 10.0.10 · RyuJIT AVX2 · BenchmarkDotNet 0.15.2 · run time 00:04:12
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
| Split | 690.6 us | 42.82 us | 122.2 us | 647.5 us | 1.03 | 0.24 | 66.4063 | 280000 B | 1.00 |
| Substring | 810.4 us | 79.98 us | 233.3 us | 713.0 us | 1.21 | 0.40 | 50.7813 | 216000 B | 0.77 |
| Span | 791.2 us | 84.53 us | 246.6 us | 708.5 us | 1.18 | 0.41 | - | - | 0.00 |
อ่านตรงตัวคือ Span ที่ 791.2 us ช้ากว่า Split ที่ 690.6 us อยู่ 14.6% แต่ StdDev ของ Span คือ 246.6 us ซึ่งใหญ่กว่าส่วนต่างนั้นสองเท่ากว่า
ล็อกเดียวกันยังพิมพ์สถิติละเอียดไว้ให้ด้วย Split ได้ StdErr=12.601 us (1.82%) N=94 CI99.9%=[647.821; 733.461] Margin=42.820 us (6.20% ของ Mean) ส่วน Span ได้ StdErr=24.907 us (3.15%) N=98 Margin=84.526 us (10.68% ของ Mean)
อ่านตาราง BDN ทีละ column
หัวข้อที่มีชื่อว่า “อ่านตาราง BDN ทีละ column”ความหมายของแต่ละ column ตามที่ BenchmarkDotNet พิมพ์ไว้เองใน block Legends ใต้ตาราง โดยเน้น2 column ที่คนอ่านผิดบ่อยที่สุด
- Mean — ค่าเฉลี่ยเลขคณิตของทุกการวัด
- Error — ครึ่งหนึ่งของช่วงความเชื่อมั่น 99.9% ไม่ใช่ค่าคลาดเคลื่อนของการวัดครั้งเดียว
- StdDev — ส่วนเบี่ยงเบนมาตรฐานของทุกการวัด
- Median — ค่ากลางเมื่อเรียงลำดับแล้ว มีไว้ให้เทียบกับ Mean เพื่อจับการกระจายที่เบ้
- Ratio — Mean หารด้วย Mean ของ baseline (แถว
Splitในตารางนี้) - RatioSD — ส่วนเบี่ยงเบนมาตรฐานของ Ratio
- Gen0 — จำนวนครั้งที่ GC เก็บกวาด gen 0 ต่อ 1,000 operation ไม่ใช่จำนวน byte
- Allocated — จำนวน byte ที่จองต่อ1 operation รวมทุกชนิดของ managed allocation
- Alloc Ratio — Allocated หารด้วย Allocated ของ baseline
2 column ที่ต้องอ่านให้ถูกคือ Error กับ Gen0 คนที่อ่าน Error เป็น “ความคลาดเคลื่อนของเครื่องมือ” จะประเมินความไม่แน่นอนต่ำไปมาก และคนที่อ่าน Gen0 เป็น byte จะเทียบตัวเลขข้ามแถวผิดทันที
ส่วนขีด - ใน column Allocated ของแถว Span ไม่ได้แปลว่า BDN วัดไม่ได้ มันคือวิธีที่ BDN พิมพ์เลขศูนย์
รันไบนารีตัวเดิมซ้ำ อันดับเวลาพลิก แต่ Allocated ไม่ขยับสัก byte
หัวข้อที่มีชื่อว่า “รันไบนารีตัวเดิมซ้ำ อันดับเวลาพลิก แต่ Allocated ไม่ขยับสัก byte”ผลรอบที่ 2 จากไบนารีเดียวกันบนเครื่องเดียวกัน ซึ่งสลับอันดับเวลาทั้งตาราง ขณะที่ column Allocated กับ Gen0 ตรงกันทุกหลัก
ไม่มีการแก้ code แม้แต่ตัวอักษรเดียว คำสั่งที่รันซ้ำคือบรรทัดเดียวกันเป๊ะ
dotnet run -c Release| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
| Split | 1,274.6 us | 150.01 us | 437.6 us | 1,212.3 us | 1.12 | 0.55 | 66.4063 | 280000 B | 1.00 |
| Substring | 892.3 us | 53.90 us | 155.5 us | 875.2 us | 0.79 | 0.30 | 50.7813 | 216000 B | 0.77 |
| Span | 1,230.3 us | 189.01 us | 557.3 us | 1,053.9 us | 1.08 | 0.63 | - | - | 0.00 |
สิ่งที่เปลี่ยน คือ column เวลาทั้ง column
- Split: 690.6 → 1,274.6 us (+84.6%) จากอันดับ 1 เร็วสุด กลายเป็นอันดับ 3 ช้าสุด
- Substring: 810.4 → 892.3 us จากอันดับ 3 กลายเป็นอันดับ 1
- Span: 791.2 → 1,230.3 us
- รอบที่สองมีคำเตือน MultimodalDistribution เพิ่มเข้ามา (Split mValue=2.86, Substring bimodal mValue=4.07)
สิ่งที่ไม่เปลี่ยนแม้แต่หลักเดียว คือ column byte
- Gen0: 66.4063 / 50.7813 / - เท่ากันเป๊ะทั้งสามแถว
- Allocated: 280000 B / 216000 B / - เท่ากันเป๊ะทั้งสามแถว
- Alloc Ratio: 1.00 / 0.77 / 0.00 เท่ากันเป๊ะทั้งสามแถว
บนเครื่องเดียวกัน ไบนารีเดียวกัน input เดียวกัน (1,000 บรรทัดของ ORD-10482,742,Confirmed,145000,2024-06-15T18:32:07) นาฬิกาให้คำตอบสองแบบ
- รอบที่ 1: Split 690.6 us (StdDev 122.2 us) เร็วที่สุด · Span 791.2 us (StdDev 246.6 us) · Substring 810.4 us (StdDev 233.3 us) ช้าที่สุด
- รอบที่ 2: Substring 892.3 us (StdDev 155.5 us) เร็วที่สุด · Span 1,230.3 us (StdDev 557.3 us) · Split 1,274.6 us (StdDev 437.6 us) ช้าที่สุด
อันดับพลิกทั้งตาราง และ StdDev ของทุกแถวใหญ่กว่าส่วนต่างที่จะเอาไปสรุปเสียอีก ขณะที่ column Allocated อ่านได้ 280000 B / 216000 B / - เท่ากันทุกหลักทั้งสองรอบ
นี่คือหลักฐานชิ้นเดียวที่ทั้งบทตั้งอยู่บนมัน และเป็นเหตุผลที่คอร์สนี้ตัดสินทุกข้อสรุปด้วย column Allocated ไม่ใช่ column Mean
สิ่งที่ข้อมูลชุดนี้พิสูจน์ได้คือ “อันดับเวลาไม่เสถียร” กับ “allocationallocationการขอพื้นที่ก้อนใหม่บน managed heap หนึ่งครั้ง นับสะสมต่อ thread โดย runtime และเป็นตัวเลขเดียวในคอร์สนี้ที่วัดซ้ำได้ถึงระดับ byte ต่างกันจริง” มันพิสูจน์ไม่ได้ว่า Span เร็วกว่า Split และพิสูจน์ไม่ได้ว่า Split เร็วกว่า Span
ทำไม column Allocated ถึงเชื่อได้
หัวข้อที่มีชื่อว่า “ทำไม column Allocated ถึงเชื่อได้”กลไกที่อยู่ใต้ column นั้น และการเอาเครื่องวัดที่เขียนเองห้าบรรทัดไปทาบกับ BDN แล้วตรงกันทุก byte
MemoryDiagnoserMemoryDiagnoserตัวเสริมของ BenchmarkDotNet ที่อ่าน `GC.GetAllocatedBytesForCurrentThread` ก่อน-หลัง แล้วรายงานเป็น column Allocated กับ Gen0 ไม่ได้เดาและไม่ได้สุ่มตัวอย่าง มันอ่านตัวนับสะสมที่ runtime อัปเดตทุกครั้งที่มีการจองบน heap ผ่าน GC.GetAllocatedBytesForCurrentThread
เอกสาร Microsoft Learn นิยามค่าที่คืนมาว่า “จำนวน byte ทั้งหมดที่ถูกจองบน managed heap ตลอดอายุของ thread” — ไม่ใช่ค่าที่รอดจาก GC และไม่รวม native allocation ตัวเลขนี้จึงไม่ขึ้นกับว่า GC จะมาเก็บกวาดตอนไหน
ต่างจากเวลา ตรงที่มันไม่ได้ขึ้นกับว่าเครื่องกำลังทำอะไรอยู่ในนาทีนั้น จำนวน byte เป็นผลลัพธ์ของ code ที่ JIT ปล่อยออกมา ส่วนเวลาเป็นผลลัพธ์ของ code นั้น บวก ทุกอย่างที่แย่ง CPU กับ cache อยู่พร้อมกัน
เขียนเครื่องวัดเองแล้วเอาไปทาบดู
// _sink เป็น static เพื่อกันไม่ให้ escape analysis ของ .NET 10 พิสูจน์ว่า object ไม่หลุด// แล้วย้ายมันไปวางบน stack ซึ่งจะทำให้ตัวเลขที่เราต้องการวัดหายไปเงียบๆ โดยไม่มี warningstatic object? _sink;
[MethodImpl(MethodImplOptions.NoInlining)]static long Now() => GC.GetAllocatedBytesForCurrentThread();
/// <summary>อุ่นเครื่องด้วย "เวลา" (ms) แล้วค่อยวัดการเรียกครั้งเดียว</summary>[MethodImpl(MethodImplOptions.NoInlining)]static long D(Action body, int ms){ var sw = Stopwatch.StartNew(); while (sw.ElapsedMilliseconds < ms) { for (int i = 0; i < 500; i++) body(); } long before = Now(); body(); return Now() - before;}
// วัด code ชุดเดียวกับที่ BDN วัด (1,000 บรรทัดต่อ1 op)long box = D(() => { long v = 1; _sink = v; }, 400); // ค่าใช้จ่ายของการ park ค่า returnlong split = D(() => _sink = Split(), 600);long substring = D(() => _sink = Substring(), 600);long span = D(() => _sink = Span(), 600);Console.WriteLine($"{split - box} / {substring - box} / {span - box}");snippet ที่วัดในคอร์สนี้เป็น สมาชิกของ class ไม่ใช่ top-level statement — static object? _sink;
กับ method ที่ถูกวัดอยู่ใน class Program ด้วยกันทั้งหมด ถ้าวางทับ top-level statement ตรงๆ
compiler จะฟ้อง CS0106 (field เป็น top-level statement ไม่ได้) หรือ CS8803
(statement ต้องมาก่อนการประกาศชนิด) ซึ่งเป็นเรื่องของ ที่วาง ไม่ใช่ของ code ที่วัด
การ park ค่า return ลง _sink เองมีราคา คือ box ของ long = 24 B ซึ่งต้องหักออกก่อนเทียบ
| method | เครื่องวัดที่เขียนเอง | หัก box 24 B แล้ว | BDN column Allocated |
|---|---|---|---|
| Split | 280024 B | 280000 B | 280000 B |
| Substring | 216024 B | 216000 B | 216000 B |
| Span | 24 B | 0 B | - (BDN พิมพ์ - แทน 0) |
รัน 3 process ใหม่ติดกัน ได้ 280024 / 216024 / 24 เท่ากันทุก process ทุกครั้ง รวม 6 การวัดต่อ method
สองเครื่องมือคนละตัว คนละ process ให้ตัวเลขตรงกันทุก byte นั่นคือสิ่งที่ทำให้ column นี้ใช้เป็นหลักฐานได้
กับดักที่ 1 ลืม _sink แล้วตัวเลขหายไปเงียบๆ
หัวข้อที่มีชื่อว่า “กับดักที่ 1 ลืม _sink แล้วตัวเลขหายไปเงียบๆ”code ชุดเดียวกันที่ต่างกันแค่บรรทัด
_sink = o;อ่านได้ 0 B เทียบ 32 B และไม่มี warning ใดเตือน
escape analysisescape analysisการที่ JIT พิสูจน์ว่า object ไม่หลุดออกนอก method แล้วย้ายมันไปวางบน stack แทน heap โดยไม่มี warning ใด ๆ มีแต่ตัวเลขที่หายไปเงียบ ๆ ของ .NET 10 พิสูจน์ได้ว่า object ตัวไหนไม่หลุดออกนอก method แล้วย้ายมันไปวางบน stack แทน heap ผลคือคนที่วัด code ซึ่งสร้าง object แล้วไม่ทำอะไรต่อ จะได้เลข 0 B มาแล้วเข้าใจว่าเทคนิคของตัวเองไม่จองเลย
static object? _sink;
sealed class Small { public int A; public int B; public int C; }
[MethodImpl(MethodImplOptions.NoInlining)]static int MakeNoSink() { var o = new Small { A = 1042, B = 2, C = 18000 }; return o.B * o.C; }
[MethodImpl(MethodImplOptions.NoInlining)]static void MakeWithSink() { var o = new Small { A = 1042, B = 2, C = 18000 }; _sink = o; }
[MethodImpl(MethodImplOptions.NoInlining)]static int BoxNoSink() { long t = 2L * 18000; object o = t; return o is long ? 1 : 0; }
[MethodImpl(MethodImplOptions.NoInlining)]static int BoxWithSink() { long t = 2L * 18000; object o = t; _sink = o; return o is long ? 1 : 0; }
[MethodImpl(MethodImplOptions.NoInlining)]static int ArrNoSink() { int[] a = new int[4]; a[0] = 2; a[3] = 1; return a[0] + a[3]; }
[MethodImpl(MethodImplOptions.NoInlining)]static int ArrWithSink() { int[] a = new int[4]; a[0] = 2; a[3] = 1; _sink = a; return a[0] + a[3]; }| input ที่วัด | ไม่มี _sink | มี _sink | หายไป |
|---|---|---|---|
new Small { A = 1042, B = 2, C = 18000 } | 0 B | 32 B | 32 B |
object o = 2L * 18000 (box ของ long) | 0 B | 24 B | 24 B |
new int[4] | 0 B | 40 B | 40 B |
.NET 10 ย้ายขึ้น stack ได้ทั้งสามแบบ คือ object class ธรรมดา box ของ value type และ array ขนาดคงที่ ไม่มี error ไม่มี warning มีแต่ตัวเลขที่กลายเป็นศูนย์
ตัวเลขทั้งตารางเป็นเลข tier-1 เพราะอุ่นด้วยเวลา 400 ms ก่อนวัดทุกเคส ที่ tier-0 column ซ้ายยังไม่เป็น0 method ที่ไม่หลุดตัวหนึ่งในโปรแกรมเดียวกันอ่านได้ 32 B ในช่วงต้นของ process แล้วจึงกระโดดเป็น 0 B ตอนที่ JIT เลื่อนขั้นให้ ซึ่งเป็นเรื่องของกับดักถัดไปพอดี
เลข 0 B ที่ได้จากการวัดแบบนั้น เป็นคุณสมบัติของตัววัด ไม่ใช่ของเทคนิค ทุก snippet ที่วัด allocation ในคอร์สนี้จึงมี static object? _sink; พร้อมคอมเมนต์อธิบายเหตุผลกำกับไว้เสมอ
กับดักที่ 2 อุ่นเครื่องด้วยจำนวนรอบ ทั้งที่ runtime นับด้วยเวลา
หัวข้อที่มีชื่อว่า “กับดักที่ 2 อุ่นเครื่องด้วยจำนวนรอบ ทั้งที่ runtime นับด้วยเวลา”method บรรทัดเดียวกันที่อ่านได้ 224 B เมื่ออุ่น 200,000 รอบ แต่อ่านได้ 184 B เมื่ออุ่น 1,000,000 รอบ และกลไกของ tiered compilation ที่อยู่เบื้องหลัง
tiered compilationtiered compilationกลไกที่ JIT compile method แบบเร็ว-หยาบก่อน (tier-0) แล้วค่อย compile ใหม่แบบเต็ม (tier-1) เมื่อถูกเรียกบ่อยพอและตัวจับเวลาเดินครบ ทำให้ code ชุดเดียวกันรายงาน allocation คนละค่าได้ คือกลไกที่ JIT compile method แบบเร็วและหยาบก่อน (tier-0) แล้วค่อย compile ใหม่แบบเต็ม (tier-1) เมื่อ method นั้นถูกเรียกบ่อยพอ และ escape analysis ทำงานเต็มที่เฉพาะที่ tier-1
code ที่วัดคือ method ParseReturnsInt ที่ parse บรรทัด 1042|ต้มยำกุ้ง|2|18000 แล้วคืนค่า int เปลี่ยนแค่จำนวนรอบอุ่นเครื่อง แต่ละบรรทัดของผลลัพธ์คือ process ใหม่ (ค่า ms ในผลลัพธ์เป็นป้ายบอกความยาวของรอบอุ่นเครื่อง ไม่ใช่ตัวเลขประสิทธิภาพ — เงื่อนไขเต็มอยู่ใต้ block)
int n = int.Parse(Environment.GetEnvironmentVariable("CH1_N")!);var sw = Stopwatch.StartNew();for (int i = 0; i < n; i++) _ = Program.ParseReturnsInt(Program.Line);double elapsed = sw.Elapsed.TotalMilliseconds;long b = GC.GetAllocatedBytesForCurrentThread();_ = Program.ParseReturnsInt(Program.Line);long delta = GC.GetAllocatedBytesForCurrentThread() - b;Console.WriteLine($"warmup {n} รอบ = {elapsed:F1} ms -> {delta} B");warmup 1,000 รอบ = 1.4 ms -> 224 Bwarmup 50,000 รอบ = 29.7 ms -> 224 Bwarmup 200,000 รอบ = 88.2 ms -> 224 Bwarmup 1,000,000 รอบ = 298.7 ms -> 184 Bwarmup 5,000,000 รอบ = 1517.5 ms -> 184 Bcontrol ที่ปิด tiering ด้วย DOTNET_TieredCompilation=0 ให้เลข tier-1 ตั้งแต่รอบแรก
warmup 1,000 รอบ = 2.4 ms -> 184 Bwarmup 200,000 รอบ = 99.4 ms -> 184 Bค่ามิลลิวินาทีใน2 block นี้มาจาก process ละหนึ่งค่า ไม่มีค่าการกระจายพ่วง จึงใช้เป็นได้แค่ป้ายบอกว่ารอบอุ่นเครื่องกินเวลาราวเท่าไรบนเครื่อง i7-7700 เครื่องนี้ ข้อสรุปทั้งหมดของหัวข้อนี้อ่านจาก column byte ซึ่งซ้ำได้ทุกครั้ง
วัดจุดพลิกโดยตรงด้วยการอ่านเดลตาของ method ที่ไม่หลุดทุก ~10 ms ตั้งแต่วินาทีแรกของ process เดลตากระโดดจาก 32 B เป็น 0 B ที่เวลา 155 / 162 / 162 / 167 / 170 / 170 / 179 / 182 / 182 / 243 ms จาก 10 process ใหม่ มัธยฐานราว 170 ms
ห้ามจำ 170 ms เป็นเกณฑ์ ตัวเลขนั้นกระจายอยู่ระหว่าง 155 ถึง 243 ms บนเครื่องเดียวกัน สิ่งที่คงที่คือ กลไก ไม่ใช่ ตัวเลข และกลไกตามเอกสารออกแบบของ dotnet/runtime คือตัวจับเวลา 100 ms บวกกับเงื่อนไขว่า method ต้องถูกเรียกอย่างน้อย 30 ครั้ง แล้วจึง compile tier-1 บน thread เบื้องหลัง
flowchart TD
A[method ชุดเดียวกัน ซอร์สไม่เปลี่ยนสักตัวอักษร] --> B[JIT compile แบบ tier-0 ทันทีที่ถูกเรียกครั้งแรก]
B --> C{ตัวจับเวลา 100 ms เดินครบ และ method ถูกเรียกอย่างน้อย 30 ครั้ง หรือยัง}
C -- ยังไม่ครบ --> D[อ่านเดลตาได้ 224 B]
C -- ครบแล้ว --> E[compile ใหม่เป็น tier-1 บน thread เบื้องหลัง]
E --> F[escape analysis ทำงาน อ่านเดลตาได้ 184 B]
D --> G[อุ่นเครื่องด้วยจำนวนรอบ จึงอ่านเลข tier-0 มาโดยไม่มีคำเตือน]
F --> H[อุ่นเครื่องด้วยเวลา จึงอ่านเลข tier-1 ซึ่งเป็นเลขที่ app ที่รันยาวจะเจอ]
คำบรรยายภาพ: เส้นทางของ method เดียวกันภายใต้ tiered compilation · JIT compile tier-0 ให้ก่อนเสมอ แล้วเลื่อนขั้นเป็น tier-1 เมื่อตัวจับเวลา 100 ms เดินครบและ method ถูกเรียกอย่างน้อย 30 ครั้ง · ผู้ที่อุ่นเครื่องด้วยจำนวนรอบอาจยังอยู่ฝั่งซ้ายและอ่านค่า 224 B มาโดยไม่มีอะไรเตือน ส่วนผู้ที่อุ่นด้วยเวลาอ่านได้ 184 B ซึ่งเป็นค่าที่ escape analysis ทำงานแล้ว
method สองตัวนี้เนื้อในเหมือนกันบรรทัดต่อบรรทัด ต่างกันแค่บรรทัดสุดท้ายว่าคืน int หรือคืน object input เดียวกันคือ 1042|ต้มยำกุ้ง|2|18000
public sealed class OrderLine(int id, string menu, int qty, int satang){ public int Id { get; } = id; public string Menu { get; } = menu; public int Qty { get; } = qty; public int Satang { get; } = satang; public long Total => (long)Qty * Satang;}
[MethodImpl(MethodImplOptions.NoInlining)]public static int ParseReturnsInt(string line){ string[] p = line.Split('|'); var o = new OrderLine(int.Parse(p[0], CultureInfo.InvariantCulture), p[1], int.Parse(p[2], CultureInfo.InvariantCulture), int.Parse(p[3], CultureInfo.InvariantCulture)); return (int)o.Total; // o ไม่หลุดออกจาก method}
[MethodImpl(MethodImplOptions.NoInlining)]public static object ParseReturnsObject(string line){ string[] p = line.Split('|'); var o = new OrderLine(int.Parse(p[0], CultureInfo.InvariantCulture), p[1], int.Parse(p[2], CultureInfo.InvariantCulture), int.Parse(p[3], CultureInfo.InvariantCulture)); return o; // o หลุดออกไป}ParseReturnsIntอ่านได้ 184 / 184 / 184 B ทุก processParseReturnsObjectอ่านได้ 224 / 224 / 224 B ทุก process- ส่วนต่าง 40 B
ก้อนย่อยที่วัดแยกใน process เดียวกันอธิบายส่วนต่างนั้นได้พอดี "1042|ต้มยำกุ้ง|2|18000".Split('|') = 184 B และ new OrderLine(1042, "x", 2, 18000) = 40 B รวมได้ 224 B ตรงกับ ParseReturnsObject ส่วน ParseReturnsInt เหลือ 184 B พอดี แปลว่า OrderLine หายไปทั้งก้อนเพราะถูกย้ายไปวางบน stack
เงื่อนไขที่ต้องพูดพร้อมตัวเลขเสมอ: คู่นี้ปรากฏเฉพาะเมื่ออุ่นด้วยเวลา 400 ms เท่านั้น ถ้าอุ่นด้วย 200,000 รอบจะได้ 224 B ทั้งคู่และส่วนต่างเป็น 0
ตัวเลขที่ reproduce ได้เป๊ะ ก็ยังผิดได้
หัวข้อที่มีชื่อว่า “ตัวเลขที่ reproduce ได้เป๊ะ ก็ยังผิดได้”ประโยคที่แรงที่สุดของทั้งคอร์ส และหลักฐานสองด้านที่ทำให้มันไม่ใช่แค่คำเตือนลอยๆ
การรันซ้ำแล้วได้เลขเดิมเป็นเกณฑ์ที่ไม่พอ บท 8 ของคอร์สนี้วัด version v0 บน batch ขนาด 20 บรรทัดออร์เดอร์ ได้ 12312 byte ซ้ำเป๊ะสามครั้งติดและข้าม process ด้วย แต่เลขนั้นวัดใต้ TieredPGO ที่ถูกปิดไว้ใน file project ที่ใช้วัดเอง ซึ่งไม่ใช่ค่าเริ่มต้นของ .NET ค่าที่ app จริงจ่ายคือ 12272 byte และส่วนต่าง 40 byte นั้นคือ enumerator ที่ถูก box ซึ่ง tier-1 บวก PGO ย้ายไปวางบน stack ได้ แต่ tier-1 เปล่าๆ ทำไม่ได้ (บท 8 กางเคสนี้เต็ม ๆ)
อีกสองเคสเป็นรูปเดียวกันแต่คนละกลไก คราวนี้เป็น tier ตรงๆ บท 6 วัด Utf8.TryWrite ที่เขียนบรรทัดออร์เดอร์เต็มลง buffer อ่านได้ 72 B นิ่งทั้งสี่รอบ แต่ที่ tier-1 อ่านได้ 0 B และบท 3 เรียก flags.HasFlag(Rush) หกครั้ง อ่านได้ 288 B ตอนที่ code ยังเป็น tier-0 แต่ได้ 0 B เมื่อเลื่อนขั้นแล้ว
จุดที่คมที่สุดอยู่ตรงนี้ บนเครื่องเดียวกัน runtime เดียวกัน การวัดชิ้นหนึ่งเชื่อได้ อีกชิ้นเชื่อไม่ได้ และสิ่งที่แยกสองชิ้นนั้นออกจากกันคือ static field ตัวเดียว
หลักฐานด้านแรก เครื่องมือ generator ที่ผลิตข้อมูลให้ภาพประกอบเชิงโต้ตอบของคอร์สนี้ (จะโผล่ในบท 3, 4 และ 6) ถูกรันซ้ำภายใต้ DOTNET_TieredCompilation=0 แล้ว diff กับผลของการรันปกติ ผลออกมาเหมือนกันทุก byte และ cmp ไม่รายงานความต่างสักตำแหน่ง
เหตุผลคือทุก object ที่ generator วัด ถูกจอดไว้ใน static object? _sink ไม่มี JIT tier ไหนลบมันทิ้งได้ ตัวเลขชุดนั้นจึงปลอดภัยจากกับดัก tiering เพราะ วินัยที่ตั้งใจทำ ไม่ใช่เพราะโชคดี
หลักฐานด้านที่สอง คือตัวเลขคู่ที่ต่างกันตามบริบทการ compile ข้างบนทั้งชุด ซึ่งเกิดใน code วัดที่ ไม่มี วินัยนั้น
สรุปเป็นกฎที่ใช้ได้จริง คือการวัดที่เชื่อได้ต้องผ่านสามอย่างพร้อมกัน อุ่นถึง tier-1 จริง อ่านซ้ำได้เท่ากันสามครั้ง และ cross-check ด้วยเครื่องมือคนละตัว
ใน loop เดียวกัน byte เท่ากันทุกรอบ เวลาต่างกันได้ถึง 15 เท่า
หัวข้อที่มีชื่อว่า “ใน loop เดียวกัน byte เท่ากันทุกรอบ เวลาต่างกันได้ถึง 15 เท่า”การวัดเวลากับ byte พร้อมกันใน loop เดียว ซึ่งแยกให้เห็นชัดว่า column ไหนเป็นของ code และ column ไหนเป็นของเครื่อง
// อุ่นเครื่องแล้ววัดเวลากับ byte ของ Split() ตัวเดียวกัน 20 รอบติดกันvar ms = new double[20];var by = new long[20];for (int i = 0; i < 20; i++){ long b0 = GC.GetAllocatedBytesForCurrentThread(); long t = Stopwatch.GetTimestamp(); _sink = Split(); double el = Stopwatch.GetElapsedTime(t).TotalMilliseconds; by[i] = GC.GetAllocatedBytesForCurrentThread() - b0; ms[i] = el;}| process | เวลา mean | เวลา StdDev | min | max | max/min | byte ทุกรอบ |
|---|---|---|---|---|---|---|
| 1 | 0.734 ms | 0.136 ms (18.6%) | 0.618 ms | 1.214 ms | 1.96x | 280024 B |
| 2 | 1.041 ms | 0.663 ms (63.7%) | 0.641 ms | 3.331 ms | 5.20x | 280024 B |
| 3 | 0.450 ms | 0.164 ms (36.4%) | 0.342 ms | 0.949 ms | 2.77x | 280024 B |
| 4 | 1.370 ms | 1.728 ms (126.2%) | 0.510 ms | 6.499 ms | 12.74x | 280024 B |
| 5 | 1.408 ms | 1.872 ms (132.9%) | 0.577 ms | 8.692 ms | 15.06x | 280024 B |
ค่าเฉลี่ยเวลาระหว่าง process อยู่ระหว่าง 0.450 ถึง 1.408 ms ต่างกัน 3.1 เท่า จาก code ชุดเดียวกัน ไบนารีเดียวกัน เครื่องเดียวกัน
จำนวน byte อ่านได้ 280024 B ทุกรอบ ทุก process ไม่มีข้อยกเว้น รวม 100 การวัด ตัวเลขนี้คือ 280000 B ของงานจริง บวก 24 B ของ box ที่ park ค่า return
เครื่องมือวัดเองก็จองด้วย และมันปนเข้าไปในตัวเลขได้ถ้าวางผิดที่ Stopwatch.StartNew() = 40 B, box ของ long = 24 B, new object() = 24 B — เอา Stopwatch.StartNew() ไปวางในบริเวณที่กำลังวัด allocation แล้วตัวเลขจะขยับจาก 280024 B เป็น 280064 B พอดี
ขีดจำกัดที่ต้องเขียนตรงๆ
หัวข้อที่มีชื่อว่า “ขีดจำกัดที่ต้องเขียนตรงๆ”สิ่งที่ข้อมูลชุดนี้ยังพิสูจน์ไม่ได้ และจะไม่ถูกอ้างในอีกเจ็ดบทข้างหน้า
- column Allocated ไม่ได้แม่นเสมอในทุกสถานการณ์ เอกสาร BenchmarkDotNet ระบุว่า
[MemoryDiagnoser]แม่น 99.5% เมื่อใช้ค่าเริ่มต้นหรือJob.ShortRunขึ้นไป สิ่งที่วัดได้บนเครื่องนี้คือ “ตรงกันทุกหลักในสามเคสที่ตรวจสอบข้าม” ไม่ใช่ “แม่นเสมอ” - byte ที่ประหยัดได้ ไม่เท่ากับเวลาที่ประหยัดได้ คอร์สนี้ไม่มีหลักฐานเรื่องนั้นและจะไม่อ้าง
- escape analysis ไม่ได้ช่วย code ของคุณโดยอัตโนมัติ ทุกเคสที่วัดได้ 0 B ในบทนี้เป็นเคสสังเคราะห์ ในบทนี้มันคือ กับดักของการวัด ไม่ใช่ เทคนิคเพิ่มความเร็ว
- ห้ามอ้างจุดพลิก tier-1 เป็นค่าคงที่ วัดได้ 155–243 ms จาก 10 process บนเครื่องนี้
- ตัวเลขขนาดก้อนทุกตัวผูกกับ x64 (IntPtr = 8) และ .NET 10.0.10 บนสถาปัตยกรรมอื่นตัวเลขชุดนี้เปลี่ยน
- allocation ไม่เท่ากับ residency คอร์สนี้นับ byte ที่จัดสรร ไม่ได้นับ byte ที่ยังค้างอยู่ในหน่วยความจำ และไม่รับปากเรื่อง GC pause, throughput, working set หรือ tail latency
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”เส้นขอบเขตสี่ข้อที่บทนี้ประกาศ และรายการหลักฐานที่บทนี้วางไว้ให้อีกเจ็ดบทใช้ต่อ
เส้นขอบเขตสี่ข้อที่ประกาศในบทนี้ และอีกเจ็ดบทจะบังคับใช้เงียบๆ
- ข้อสรุปว่า “เทคนิคนี้ดีกว่า” ตัดสินด้วย column Allocated เท่านั้น ไม่ใช่ column Mean
- ตัวเลขเวลาทุกตัวต้องพ่วงเครื่องที่วัดและค่าการกระจาย พ่วงไม่ได้ = ตัดทิ้ง
- byte ที่ประหยัดได้ ไม่เท่ากับเวลาที่ประหยัดได้
- “รันสองรอบได้เท่ากัน” ยังไม่พอ ต้องอุ่นถึง tier-1 จริงก่อน
สิ่งที่บทนี้วัดไว้ให้ครบแล้ว และอีกเจ็ดบทจะยกไปใช้ต่อโดยไม่ต้องพิสูจน์ซ้ำ
- ไบนารีเดียวกันรันสองรอบให้อันดับเวลาตรงข้ามกัน ขณะที่ Allocated อ่านได้ 280000 B / 216000 B / - เท่ากันทุกหลัก
- เครื่องวัดห้าบรรทัดที่เขียนเอง ตรงกับ column Allocated ของ BDN ทุก byte ทั้งที่คนละ process คนละเครื่องมือ
- การลืม
_sinkเปลี่ยน 32 B ให้เป็น 0 B โดยไม่มีคำเตือนใดๆ - การอุ่นด้วย 200,000 รอบให้ 224 B ส่วน 1,000,000 รอบให้ 184 B จากบรรทัดเดียวกัน
บท 2 เริ่มจากคำถามที่ทุกคนถามก่อนเสมอ: เปลี่ยน class เป็น struct แล้วจะเร็วขึ้นไหม คำตอบสั้นๆ คือ struct ไม่ได้แปลว่าอยู่บน stack และไม่ได้แปลว่าเร็วกว่า และบทหน้าจะเป็นบทเดียวในคอร์สที่การนับ allocation ตอบคำถามไม่ได้ เพราะทุกเคสวัดได้ 0 B เท่ากันหมด เราจึงต้องใช้หลักฐานคนละชนิดแทน
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- GC.GetAllocatedBytesForCurrentThread Method (System) — Microsoft Learn (net-10.0) (2026-07-01) — นิยามว่าค่าที่คืนคือจำนวน byte ทั้งหมดที่ถูกจองบน managed heap ตลอดอายุของ thread ไม่ใช่ค่าที่รอดจาก GC และไม่รวม native allocation
- BenchmarkDotNet — Diagnosers (ดึงข้อมูล 2026-08-07, BDN v0.15.x) — นิยาม column Gen0/Gen1/Gen2 ว่าเป็นจำนวนครั้งที่ GC เก็บกวาดต่อ 1,000 operation และคำกล่าวอ้างว่า
[MemoryDiagnoser]แม่น 99.5% - Tiered compilation — dotnet/runtime design docs (ดึงข้อมูล 2026-08-07) — กลไกตัวจับเวลา 100 ms บวกเงื่อนไขว่า method ต้องถูกเรียกอย่างน้อย 30 ครั้ง ก่อน compile tier-1 บน thread เบื้องหลัง
- Compilation config settings — Microsoft Learn (2025-12-03) —
DOTNET_TieredCompilation,TieredPGOและTC_QuickJitซึ่งเป็นสวิตช์ที่ใช้ทำ control ในบทนี้ - Performance Improvements in .NET 10 — .NET Blog (ดึงข้อมูล 2026-08-07) — escape analysis กับ object stack allocation ที่เป็นเหตุผลว่าทำไม
OrderLineถึงหายไปทั้งก้อนเมื่อไม่หลุดออกนอก method
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3รอบที่ 1 ของตาราง BDN อ่านได้ Split 690.6 us และ Span 791.2 us บนเครื่อง i7-7700 เครื่องเดียวกัน ข้อสรุปใดที่ข้อมูลชุดนี้รองรับ