ข้าม​ไป​ยัง​เนื้อหา

วัด​ให้​เป็น​ก่อน: ทำไม column Allocated เชื่อ​ได้ และ column Mean เชื่อ​ไม่​ได้

คอร์ส​นี้​จะ​ใช้​เวลา​อีก​เจ็ด​บท​ข้าง​หน้า​ไล่​ลด​จำนวน byte ที่ hot path ของ domain Order จอง​บน heap และ​เครื่องมือ​หลัก​ที่​จะ​ใช้​คือ Span<char> แต่ benchmark ชุด​แรก​ที่​รัน​บน​เครื่อง​ที่​ใช้​เขียน​คอร์ส​นี้ กลับ​บอกว่า version Span<char> ช้า​กว่า string.Split อยู่ 14.6%

ถ้า​อ่าน​แค่ column Mean ก็​ต้อง​สรุป​ว่า​เทคนิค​ทั้ง​คอร์ส​นี้​ไร้​ประโยชน์ ข้อ​สรุป​นั้น​ผิด และ​บท​นี้​มีหน้าที่​อธิบาย​ว่า​มัน​ผิด​ตรง​ไหน

ก่อน​จะ​อ้าง​ตัวเลข​ตัว​แรก​ได้ คอร์ส​นี้​ต้อง​ปกป้อง​วิธี​วัด​ของ​ตัวเอง​ให้​ได้​ก่อน บท​นี้​จึง​ไม่​ได้​สอน​เทคนิค​เพิ่ม​ความเร็ว​สัก​ตัว​เดียว มัน​สอน​ว่า​ตัวเลข​แบบ​ไหน​ใช้​เป็น​หลักฐาน​ได้ และ​ตัวเลข​แบบ​ไหน​ที่ reproduce ได้​เป๊ะ แต่​ยัง​ผิด​อยู่ดี

📦 kaen-food-ordering

ทั้ง​คอร์ส​เดิน​บน 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:07

code ที่​วัด (ตัด​เหลือ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

MethodMeanErrorStdDevMedianRatioRatioSDGen0AllocatedAlloc Ratio
Split690.6 us42.82 us122.2 us647.5 us1.030.2466.4063280000 B1.00
Substring810.4 us79.98 us233.3 us713.0 us1.210.4050.7813216000 B0.77
Span791.2 us84.53 us246.6 us708.5 us1.180.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)

ความหมาย​ของ​แต่ละ 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 แม้แต่​ตัว​อักษร​เดียว คำ​สั่ง​ที่​รัน​ซ้ำ​คือ​บรรทัด​เดียวกันเป๊ะ

Terminal window
dotnet run -c Release
MethodMeanErrorStdDevMedianRatioRatioSDGen0AllocatedAlloc Ratio
Split1,274.6 us150.01 us437.6 us1,212.3 us1.120.5566.4063280000 B1.00
Substring892.3 us53.90 us155.5 us875.2 us0.790.3050.7813216000 B0.77
Span1,230.3 us189.01 us557.3 us1,053.9 us1.080.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 เท่า​กัน​เป๊ะ​ทั้ง​สาม​แถว
ธีสิส​ของ​คอร์ส​นี้ ถูก benchmark ของ​คอร์ส​เอง​จับ​ได้​คา​หนัง​คา​เขา

บน​เครื่อง​เดียวกัน ไบนารี​เดียวกัน 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 นั้น และ​การ​เอา​เครื่อง​วัด​ที่​เขียน​เอง​ห้า​บรรทัด​ไป​ทาบ​กับ 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 ซึ่งจะทำให้ตัวเลขที่เราต้องการวัดหายไปเงียบๆ โดยไม่มี warning
static 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 ค่า return
long 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}");
ถ้า​จะ​ลอก code ใน​คอร์ส​นี้​ไป​รัน

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
Split280024 B280000 B280000 B
Substring216024 B216000 B216000 B
Span24 B0 B- (BDN พิมพ์ - แทน 0)

รัน 3 process ใหม่​ติด​กัน ได้ 280024 / 216024 / 24 เท่า​กัน​ทุก process ทุก​ครั้ง รวม 6 การ​วัด​ต่อ method

สอง​เครื่องมือ​คนละ​ตัว คนละ process ให้​ตัวเลข​ตรง​กัน​ทุก byte นั่น​คือ​สิ่ง​ที่​ทำให้ column นี้​ใช้​เป็น​หลักฐาน​ได้

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 B32 B32 B
object o = 2L * 18000 (box ของ long)0 B24 B24 B
new int[4]0 B40 B40 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 B
warmup 50,000 รอบ = 29.7 ms -> 224 B
warmup 200,000 รอบ = 88.2 ms -> 224 B
warmup 1,000,000 รอบ = 298.7 ms -> 184 B
warmup 5,000,000 รอบ = 1517.5 ms -> 184 B

control ที่​ปิด tiering ด้วย DOTNET_TieredCompilation=0 ให้​เลข tier-1 ตั้งแต่​รอบ​แรก

warmup 1,000 รอบ = 2.4 ms -> 184 B
warmup 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 ทำงาน​แล้ว

⛔ allocation เป็น​คุณสมบัติ​ของ code บวก​บริบท​การ compile — 184 B เทียบ 224 B

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 ทุก process
  • ParseReturnsObject อ่าน​ได้ 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

ประโยค​ที่​แรง​ที่สุด​ของ​ทั้ง​คอร์ส และ​หลักฐาน​สอง​ด้าน​ที่​ทำให้​มัน​ไม่ใช่​แค่​คำ​เตือน​ลอยๆ

การ​รัน​ซ้ำ​แล้ว​ได้​เลข​เดิม​เป็น​เกณฑ์​ที่​ไม่​พอ บท 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เวลา StdDevminmaxmax/minbyte ทุกรอบ
10.734 ms0.136 ms (18.6%)0.618 ms1.214 ms1.96x280024 B
21.041 ms0.663 ms (63.7%)0.641 ms3.331 ms5.20x280024 B
30.450 ms0.164 ms (36.4%)0.342 ms0.949 ms2.77x280024 B
41.370 ms1.728 ms (126.2%)0.510 ms6.499 ms12.74x280024 B
51.408 ms1.872 ms (132.9%)0.577 ms8.692 ms15.06x280024 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

เส้น​ขอบเขต​สี่​ข้อ​ที่​บท​นี้​ประกาศ และ​รายการ​หลักฐาน​ที่​บท​นี้​วาง​ไว้​ให้​อีก​เจ็ด​บท​ใช้​ต่อ

เส้น​ขอบเขต​สี่​ข้อ​ที่​ประกาศ​ใน​บท​นี้ และ​อีก​เจ็ด​บท​จะ​บังคับ​ใช้​เงียบๆ

  1. ข้อ​สรุป​ว่า “เทคนิค​นี้​ดี​กว่า” ตัดสิน​ด้วย column Allocated เท่านั้น ไม่ใช่ column Mean
  2. ตัวเลข​เวลา​ทุก​ตัว​ต้อง​พ่วง​เครื่อง​ที่​วัด​และ​ค่า​การกระจาย พ่วง​ไม่​ได้ = ตัด​ทิ้ง
  3. byte ที่​ประหยัด​ได้ ไม่​เท่ากับ​เวลา​ที่​ประหยัด​ได้
  4. “รัน​สอง​รอบ​ได้​เท่า​กัน” ยัง​ไม่​พอ ต้อง​อุ่น​ถึง 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 เครื่องเดียวกัน ข้อสรุปใดที่ข้อมูลชุดนี้รองรับ