capstone: hot path จริง 12,272 byte → 0 byte
เจ็ดบทที่ผ่านมาวัดทีละเทคนิคบน code สั้นๆ ที่แยกออกมาเพื่อให้ตัวแปรเหลือตัวเดียว บทนี้เอาทั้งหมดมาลงบน hot pathhot pathเส้นทาง code ที่ถูกเดินซ้ำจนต้นทุนต่อรอบกลายเป็นต้นทุนของระบบ และเป็นที่เดียวที่การไล่ byte คุ้มค่า เส้นเดียวกันของ domain Order คือ parse บรรทัดออร์เดอร์ 20 บรรทัด รวมยอด แล้วพิมพ์ใบเสร็จ
ผลลัพธ์ที่วัดได้คือบันได 5 ขั้น 12272 byte ต่อ batch ลงไปถึง 0 byte โดยใบเสร็จที่พิมพ์ออกมายังยาว 982 byte เท่ากันทุก byte ทุก version
แต่บทนี้ไม่ได้มีหน้าที่ฉลองตัวเลขนั้น มันมีหน้าที่สองอย่างที่ขัดใจกว่า อย่างแรกคือเปิดกับดักที่ทำให้ตัวเลขตัวแรกของบันไดนี้ ผิด อยู่พักหนึ่งทั้งที่วัดซ้ำได้เป๊ะสามครั้งติดและข้าม process อย่างที่สองคือบอกตรงๆ ว่าการตัด allocation ทั้งหมดออกไปได้ ให้เวลากลับมาแค่ราวสี่เท่าบนเครื่องนี้ และแม้แต่ตัวเลขนั้นเองคอร์สนี้ก็ยังไม่ยอมนับเป็นหลักฐาน
บทนี้เดินบน hot path เส้นเดียวกับที่ทั้งคอร์สใช้มาตั้งแต่บท 1 คือ parse บรรทัดออร์เดอร์ → รวมยอด → พิมพ์ใบเสร็จ ของ domain Order ที่คอร์ส #8–#11 สร้างไว้ (repo ตัวอย่าง .NET กำลังจัดทำ)
ทุกตัวเลขในบทนี้วัดบนเครื่องเดียว Intel Core i7-7700 3.60GHz (Kaby Lake) · 8 logical / 4 physical · Ubuntu 24.04.4 LTS (WSL2) · linux-x64 · .NET SDK 10.0.302 / runtime 10.0.10 · RyuJIT AVX2 · workstation non-concurrent GC และทุกขนาดก้อนผูกกับ x64 ที่ IntPtr เท่ากับ 8 byte
ไวยากรณ์ C# 11–14 ที่ code ในบทนี้ใช้เต็มมือ (range operator อย่าง rest[..i] และ dst[p..], collection expression, params ReadOnlySpan<T>) คอร์สนี้ ใช้ โดยไม่หยุดอธิบาย ถ้าเจอของที่ไม่คุ้นให้เปิด C# 8 → 14: อะไรเปลี่ยนไปบ้าง ควบคู่ไป
ข้อมูลชุดเดียว function เดียว และประตูความถูกต้องที่มาก่อนตัวเลข
หัวข้อที่มีชื่อว่า “ข้อมูลชุดเดียว function เดียว และประตูความถูกต้องที่มาก่อนตัวเลข”เงื่อนไขที่ทุก version ต้องผ่านก่อนจึงจะมีสิทธิ์ถูกเอาตัวเลข allocation มาเทียบกัน คือพ่น byte ออกมาตรงกันครบ 982 byte
การเทียบ allocation ระหว่าง2 version ที่ให้ผลลัพธ์ไม่เท่ากัน ไม่ใช่การวัด มันคือการเปลี่ยนงานแล้วรายงานว่าประหยัดขึ้น บันไดทั้งบทนี้จึงตั้งประตูไว้ก่อนเป็นอย่างแรก
- input คือ order line 20 บรรทัด ชุดเดียวกันทุก version รูปแบบของแต่ละบรรทัดคือ id, ชื่อเมนู, จำนวน และราคาต่อหน่วยเป็นสตางค์ คั่นด้วยขีดตั้ง — อ่านรูปแบบตรงๆ ได้จาก code ของ v0 ในหัวข้อถัดไป
- ปลายทางคือ buffer
byte[]ขนาด 8192 byte ที่จองไว้นอกช่วงที่วัด - ผลลัพธ์คือใบเสร็จ UTF-8 ยาว 982 byte และทุก version ต้องพ่นออกมาเท่ากันทุก byte ไม่ใช่แค่ความยาวเท่ากัน
- version ที่เข้าประตูมีทั้งหมด 7 ตัว คือห้าขั้นของบันได บวกอีกสองทางเลือกของขั้นสุดท้าย และผ่านครบทั้งเจ็ด
flowchart TD
A[batch เดียวกัน order line 20 บรรทัด] --> B[v0 naive]
A --> C[v1 no-boxing]
A --> D[v2 span parse]
A --> E[v3 pooled buffer]
A --> F[v4 no-alloc format และอีกสองทางเลือก]
B --> G{ประตูความถูกต้อง byte ที่พ่นออกมาตรงกับ v0 ทุก byte หรือไม่}
C --> G
D --> G
E --> G
F --> G
G -- ไม่ตรง --> H[ตัดทิ้ง ห้ามเอาตัวเลข allocation ไปเทียบกัน]
G -- ตรงกันครบ 982 byte ทั้ง7 version --> I[จึงมีสิทธิ์อ่าน column Allocated]
I --> J[บันได 12272 B ลงมา 0 B ต่อ batch]
คำบรรยายภาพ: ลำดับของ capstone บทนี้ · input ชุดเดียวเดินเข้า7 version แล้วทุก version ต้องผ่านประตูความถูกต้องคือพ่น byte ออกมาตรงกันครบ 982 byte ก่อน จึงจะถูกเอาตัวเลข allocation มาเรียงเทียบกันได้ · version ที่ไม่ผ่านประตูถูกตัดทิ้ง ไม่ใช่ถูกรายงานว่าประหยัดกว่า
คอร์สนี้พิมพ์ input กำกับตัวเลขทุกตัวมาตลอดเจ็ดบท แต่ตรงนี้มีช่องว่างที่ต้องพูดตรงๆ บันทึกการวัดของ capstone เก็บ ลายนิ้วมือเชิงปริมาณ ของ batch ไว้ ไม่ได้เก็บข้อความทั้ง 20 บรรทัดไว้คำต่อคำ
สิ่งที่บันทึกไว้และตรวจสอบซ้ำได้คือ batch มี 20 บรรทัด, รวมกันยาว 464 UTF-16 chars และ 894 byte เมื่อเข้ารหัส UTF-8 (M8.8), ปลายทางคือ byte[] 8192 byte และผลลัพธ์คือ 982 byte เสมอ
การเดาข้อความ 20 บรรทัดขึ้นมาเองให้ตารางดูครบ จะทำให้ทุกตัวเลขในบทนี้ reproduce ไม่ได้ในทางที่แย่กว่าเดิม คือแย่แบบที่มองไม่เห็น ผมจึงพิมพ์ลายนิ้วมือให้แทน แล้วบอกไว้ตรงนี้ว่ามันคือลายนิ้วมือ ไม่ใช่ตัวข้อมูล
อ่านบิล 12272 byte ของ v0 ว่าใครกินเท่าไร
หัวข้อที่มีชื่อว่า “อ่านบิล 12272 byte ของ v0 ว่าใครกินเท่าไร”version แรกเขียนแบบที่ code production ส่วนใหญ่เขียนกันจริง แล้ววัดราคาของแต่ละส่วนแยกออกมาทีละก้อน
v0 ไม่ใช่ code ที่แกล้งเขียนให้แย่ มันคือรูปทรงที่พบได้ทั่วไป คือ parse ด้วย Split เก็บลง List<T> ที่ประกาศเป็น interface รวมยอดด้วย LINQ จัดรูปเงินด้วย ToString + PadLeft + Substring แล้วต่อบรรทัดด้วย string.Format
using System.Text;
public static int RenderV0(string[] lines, byte[] dest){ var parsed = new List<IOrderLine>(); // List + boxing ทุก element foreach (string line in lines) { string[] parts = line.Split('|'); // string[] + 4 string ต่อบรรทัด parsed.Add(new OrderLine( int.Parse(parts[0]), parts[1], int.Parse(parts[2]), int.Parse(parts[3]))); }
long grand = parsed.Sum(o => (long)o.Qty * o.UnitSatang); // LINQ
int pos = 0; foreach (IOrderLine o in parsed) { long lineTotal = (long)o.Qty * o.UnitSatang; string money = NaiveMoney(lineTotal); // ToString + PadLeft + 2 Substring string receipt = string.Format("{0}|{1} x{2}|{3}\n", o.OrderId, o.Menu, o.Qty, money); // object[] + box int 2 ตัว byte[] utf8 = Encoding.UTF8.GetBytes(receipt); // byte[] ต่อบรรทัด Array.Copy(utf8, 0, dest, pos, utf8.Length); pos += utf8.Length; }
string totalLine = string.Format("TOTAL|{0}|{1}\n", parsed.Count, NaiveMoney(grand)); byte[] tail = Encoding.UTF8.GetBytes(totalLine); Array.Copy(tail, 0, dest, pos, tail.Length); return pos + tail.Length;}
private static string NaiveMoney(long satang){ string s = satang.ToString(); if (s.Length < 3) s = s.PadLeft(3, '0'); return s.Substring(0, s.Length - 2) + "." + s.Substring(s.Length - 2);}| ส่วนของ v0 | input ที่วัด | B/batch |
|---|---|---|
Split ที่ตัดด้วยตัวคั่นขีดตั้ง | 20 บรรทัดของ batch | 3832 B |
NaiveMoney คือ ToString + PadLeft + Substring สองครั้ง | ยอดของ 20 บรรทัด บวกยอดรวม | 2856 B |
string.Format ที่รับ object[] ⚠️ | 21 บรรทัดของใบเสร็จ | 2520 B — เป็นไปไม่ได้ ดูกล่องข้างล่าง |
Encoding.UTF8.GetBytes(string) | 21 บรรทัดของใบเสร็จ | 1512 B |
List<IOrderLine> บวก box ทุก element | 20 element | 1408 B |
| รวมทั้ง method | batch 20 บรรทัด → 982 byte | 12272 B |
ห้าบรรทัดบนคือ ราคาของแต่ละส่วนที่วัดแยก ไม่ใช่การถอดยอด 12272 byte ออกให้ครบทุก byte คอร์สนี้จึงจะไม่บวกมันให้ดูแล้วอ้างว่าลงตัว ประโยชน์ของรายการนี้อยู่ที่การจัดลำดับว่าควรแตะอะไรก่อน
บรรทัดนี้ถูกเพิ่มทีหลัง ตอนที่มีคนพยายามกู้ batch 20 บรรทัดที่หายไปกลับมา แล้วพบว่ากู้ไม่ได้ — แต่ระหว่างทางเจอว่า 2520 B เป็นค่าที่ไม่มี batch ไหนในจักรวาลนี้ทำให้เกิดได้
นับจาก code ของ v0 ตรง ๆ: string.Format ถูกเรียก 21 ครั้ง (20 บรรทัดใบเสร็จ บรรทัดละ 4 argument
บวกบรรทัด TOTAL อีกหนึ่งครั้ง 2 argument) และในนั้นมี int ที่ต้อง box รวม 41 ตัว
บท 3 วัดไว้แล้วว่า box ของ int = 24 B ⇒ ค่า box อย่างเดียว = 984 B
ส่วน string ที่ 21 บรรทัดนั้นผลิตออกมา ความยาวรวมถูกล็อกไว้แล้วด้วยตัวเลขที่บทนี้ประกาศไปตั้งแต่ต้น:
ใบเสร็จหนัก 982 byte และมันแบก “ส่วนเกินหลาย byte” ของชื่อเมนูทั้งหมดไว้พอดี (894 − 464 = 430)
ดังนั้นจำนวน อักขระ ของทั้ง 21 บรรทัดคือ 982 − 430 = 552 เป๊ะ · string ยาว L อักขระมีราคาอย่างต่ำ
22 + 2L byte ⇒ string ทั้งหมดอย่างต่ำ 21 × 22 + 2 × 552 = 1566 B
รวมพื้นอย่างต่ำ = 984 + 1566 = 2550 B ซึ่ง มากกว่า 2520 B ที่ตารางบอก และความยาวของ
บรรทัด TOTAL ตัดกันหายไปในสมการ แปลว่าขอบล่างนี้ไม่ขึ้นกับหน้าตาของ batch เลยแม้แต่นิดเดียว
ที่น่าสนใจกว่าคือมันอธิบายช่องว่างที่บทนี้ยอมรับไว้เอง ห้าแถวข้างบนบวกกันได้ 12128 B ขาดจาก 12272 B อยู่ 144 B — ซึ่งบทนี้เคยอธิบายว่าเป็น “ส่วนที่ไม่ได้วัดแยก” · ตอนนี้รู้แล้วว่าอย่างน้อย 30 B ของช่องว่างนั้นไม่ใช่ของที่ไม่ได้วัด แต่เป็นแถวที่วัดมาผิด ค่าที่ถูกต้องขึ้นกับความยาวของ แต่ละบรรทัดใบเสร็จ (เพราะการปัดขึ้นทีละ 8 byte) จึงระบุเป็นเลขเดียวไม่ได้เมื่อ batch เดิมหายไปแล้ว บอกได้แค่ว่า ไม่ต่ำกว่า 2550 B
นี่คือกฎของคอร์สนี้ย้อนกลับมาเล่นงานบทสุดท้ายของคอร์สเอง: ตัวเลขนี้ผ่านทั้งการวัด การรีวิวรายบท และการรีวิวทั้งสาขา เพราะทุกด่านตรวจว่ามัน ตรงกับที่บันทึกไว้ ไม่มีด่านไหนถามว่ามัน เป็นไปได้ไหม เลขที่ทำซ้ำได้ยังผิดได้ — และเลขที่ trace กลับไปหาบันทึกได้ ก็ยังผิดได้เหมือนกัน
ที่น่าสนใจคือลำดับนั้นไม่ตรงกับที่คนส่วนใหญ่เดา งานที่แพงที่สุดไม่ใช่ boxing ที่ตาเห็นชัดที่สุด (1408 B) แต่เป็น Split (3832 B) กับการจัดรูปเงิน (2856 B) ซึ่งเป็นสองบรรทัดที่ดูไม่มีพิษภัยที่สุดใน method
บันได 5 ขั้น 12272 → 0 และขั้นที่คุ้มที่สุดไม่ใช่ขั้นสุดท้าย
หัวข้อที่มีชื่อว่า “บันได 5 ขั้น 12272 → 0 และขั้นที่คุ้มที่สุดไม่ใช่ขั้นสุดท้าย”ตัวเลขทั้งบันไดวัดด้วยตัวนับต่อ thread ในสภาวะอุ่นแล้ว และตรงกับ column Allocated ของ BenchmarkDotNet ทุกบรรทัด
long b = GC.GetAllocatedBytesForCurrentThread();for (int i = 0; i < 1000; i++) fn(Batch.Lines, dest);long a = GC.GetAllocatedBytesForCurrentThread();return (a - b) / 1000;| ขั้น | input ที่วัด | B/batch | delta จากขั้นก่อน |
|---|---|---|---|
| v0 naive | Batch.Lines 20 บรรทัด → dest เป็น byte[] 8192 byte | 12272 B | — |
| v1 no-boxing | input ชุดเดียวกันทุก byte | 9880 B | −2392 B |
| v2 span parse | input ชุดเดียวกันทุก byte | 6048 B | −3832 B |
| v3 pooled buffer | input ชุดเดียวกันทุก byte | 1560 B | −4488 B |
| v4 no-alloc format | input ชุดเดียวกันทุก byte | 0 B | −1560 B |
ขั้นที่คุ้มที่สุดไม่ใช่ขั้นสุดท้าย แต่เป็น v2 และเหตุผลไม่ได้อยู่ที่ขนาดของ delta
public static int RenderV2(string[] lines, byte[] dest){ long grand = 0; int pos = 0; int count = 0;
foreach (string line in lines) { ReadOnlySpan<char> rest = line; int i = rest.IndexOf('|'); int orderId = int.Parse(rest[..i]); rest = rest[(i + 1)..];
i = rest.IndexOf('|'); ReadOnlySpan<char> menu = rest[..i]; // ไม่ copy — ชี้เข้าไปใน line เดิม rest = rest[(i + 1)..];
i = rest.IndexOf('|'); int qty = int.Parse(rest[..i]); int unit = int.Parse(rest[(i + 1)..]);
long lineTotal = (long)qty * unit; grand += lineTotal; count++;
string money = NaiveMoney(lineTotal); string receipt = $"{orderId}|{menu} x{qty}|{money}\n"; byte[] utf8 = System.Text.Encoding.UTF8.GetBytes(receipt); Array.Copy(utf8, 0, dest, pos, utf8.Length); pos += utf8.Length; }
string totalLine = $"TOTAL|{count}|{NaiveMoney(grand)}\n"; byte[] tail = System.Text.Encoding.UTF8.GetBytes(totalLine); Array.Copy(tail, 0, dest, pos, tail.Length); return pos + tail.Length;}v2 ตัดไป 3832 byte ซึ่งเท่ากับราคาของ Split ที่วัดแยกไว้ใน M8.2 พอดี และมันทำได้โดยมี จำนวนบรรทัด code เท่ากับ v0 คือ 30 บรรทัด ไม่ต้องเพิ่ม helper ไม่ต้องเพิ่ม try/finally ไม่ต้องรับความเสี่ยงใหม่สักอย่าง สิ่งที่เปลี่ยนคือเลิกให้ Split สร้าง string[] หนึ่งก้อนบวก string อีกสี่ก้อนต่อบรรทัด แล้วเดินด้วย ReadOnlySpan<char> ที่ชี้กลับเข้าไปใน string เดิมแทน
ส่วน v4 คือขั้นที่ไปถึง zero allocationzero allocationสภาวะที่เส้นทางหนึ่งวัดได้ 0 byte ต่อรอบใน steady state โดยไม่นับต้นทุนครั้งแรก จริง คือเขียน char ลง buffer บน stack เอง แล้วเข้ารหัสจาก span ไปยัง span ตรงๆ โดยไม่มี string กลางทางเลยสักตัว
using System.Text;
public static int RenderV4cStack(string[] lines, byte[] dest){ Span<char> scratch = stackalloc char[256]; long grand = 0; int pos = 0; int count = 0;
foreach (string line in lines) { ParseLine(line, out int orderId, out ReadOnlySpan<char> menu, out int qty, out int unit); long lineTotal = (long)qty * unit; grand += lineTotal; count++;
int len = WriteReceiptChars(scratch, orderId, menu, qty, lineTotal); pos += Encoding.UTF8.GetBytes(scratch[..len], dest.AsSpan(pos)); // span -> span }
int tlen = WriteTotalChars(scratch, count, grand); return pos + Encoding.UTF8.GetBytes(scratch[..tlen], dest.AsSpan(pos));}
private static int WriteMoney(Span<char> dst, long satang){ long baht = satang / 100; int cc = (int)(satang % 100); baht.TryFormat(dst, out int w); dst[w] = '.'; dst[w + 1] = (char)('0' + cc / 10); dst[w + 2] = (char)('0' + cc % 10); return w + 3;}v4 อ่านได้ 0 B/batch และ BenchmarkDotNet แสดงทั้ง column Allocated และ Gen0 เป็นขีด ที่สำคัญกว่าคือมันผ่านประตูความถูกต้อง ใบเสร็จที่ออกมายาว 982 byte ตรงกับ v0 ทุก byte
ตัวเลขที่ทำซ้ำได้เป๊ะสามครั้งข้าม process แล้วยังผิด
หัวข้อที่มีชื่อว่า “ตัวเลขที่ทำซ้ำได้เป๊ะสามครั้งข้าม process แล้วยังผิด”กับดักที่ใหญ่กว่าตัวเลขทุกตัวในบันได และเป็นข้อเดียวกับที่บท 1 ประกาศไว้ตั้งแต่หน้าแรกของคอร์ส
ตัวเลข 12272 ที่ยืนต้นบันไดอยู่ตอนนี้ ไม่ใช่ตัวเลขที่วัดได้ตอนแรก ตอนแรกวัดได้ 12312 byte (เลขนี้ต้องมีป้าย TieredPGO ปิด ติดอยู่ด้วยเสมอ — เหตุผลอยู่ข้างล่าง) และมันทำซ้ำได้ดีมาก คือเท่ากันเป๊ะสามครั้งติดที่ระดับ warm-up 100,000 / 200,000 / 300,000 รอบ และเท่ากันข้าม process ด้วย ถ้าเกณฑ์ของคุณคือ “รันซ้ำแล้วได้เลขเดิม” ตัวเลขนี้ผ่านฉลุย
สาเหตุอยู่ใน file project ไม่ใช่ใน code
<!-- Ladder.csproj — บรรทัดนี้คือปัญหา --><PropertyGroup> <Optimize>true</Optimize> <ServerGarbageCollection>false</ServerGarbageCollection> <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection> <!-- <TieredPGO>false</TieredPGO> <-- ใส่แล้ว v0 อ่านได้ 12312 แทน 12272 --></PropertyGroup>| input ที่วัด | v0 อ่านได้ |
|---|---|
Batch.Lines 20 บรรทัด ภายใต้ค่าเริ่มต้นของ .NET คือ TieredPGO เปิดอยู่ | 12272 B |
Batch.Lines ชุดเดียวกัน ภายใต้ DOTNET_TieredPGO=0 | 12312 B |
ส่วนต่าง 40 byte อธิบายได้ครบ คือ enumerator ที่ถูก box ของ List<IOrderLine> ซึ่ง Enumerable.Sum ขอมา ที่ tier-1 พร้อม PGO runtime ย้ายมันไปวางบน stack ได้ แต่ tier-1 เปล่าๆ ที่ไม่มี PGO ทำไม่ได้ ขนาด code ที่ JIT ปล่อยออกมาสำหรับ RenderV0 ที่ tier-1 ต่างกันชัดเจนเช่นกัน คือ 5877 byte เมื่อมี PGO และ 1408 byte เมื่อไม่มี — เลข 1408 ตัวนี้เป็นขนาด code ที่ JIT ปล่อยออกมา คนละหน่วยกับ 1408 B ในบิลของ v0 ข้างบนที่เป็น byte ที่จัดสรร ส่วน v1 ถึง v4 ไม่ขยับเลยสัก byte ทั้งสองแบบ
เลข 12312 B ผ่านทุกเกณฑ์ที่คนส่วนใหญ่ใช้ตัดสินว่าการวัดเชื่อได้ คืออ่านซ้ำเท่ากันสามครั้ง ข้าม process ก็เท่ากัน และไบนารีตัวเดียวกัน
มันยังผิดอยู่ดี เพราะสิ่งที่มันวัด ไม่ใช่สิ่งที่ app ของคุณจะเจอ — มันวัด code ใต้ TieredPGO ที่ถูกปิดไว้ใน file วัดผลเอง ส่วนค่าเริ่มต้นของ .NET คือเปิด และเป็นค่าที่ BenchmarkDotNet รายงาน
ห้ามใส่ <TieredPGO>false</TieredPGO> ใน project ที่คุณจะเอาไปวัด สวิตช์แบบนี้มีไว้ทำ control เพื่อเทียบ tier เท่านั้น และเมื่อไรที่เอ่ยเลข 12312 ต้องมีป้าย TieredPGO ปิด ติดอยู่ในย่อหน้าเดียวกันเสมอ
เกณฑ์ที่ใช้ได้จริงมีสามข้อพร้อมกัน คือ อุ่นถึง tier-1 จริง อ่านซ้ำเท่ากันสามครั้ง และ cross-check ด้วยเครื่องมือคนละตัว การทำซ้ำได้เพียงอย่างเดียวไม่ใช่หลักฐาน มันเป็นแค่เงื่อนไขที่จำเป็น
กับดักฝาแฝดของมันอยู่ที่ทางเลือกหนึ่งของขั้นสุดท้าย Utf8.TryWrite เป็น API ที่โฆษณาว่าเขียนลง buffer โดยไม่จอง และมันจริง — แต่เฉพาะหลัง JIT เลื่อนขั้นให้แล้ว
using System.Text.Unicode;
// version นี้ควรเป็น 0 byte และมัน 0 จริง — แต่เฉพาะเมื่อ JIT ขึ้น tier-1 แล้วpublic static int RenderV4(string[] lines, byte[] dest){ long grand = 0; int pos = 0; int count = 0;
foreach (string line in lines) { ParseLine(line, out int orderId, out ReadOnlySpan<char> menu, out int qty, out int unit); long lineTotal = (long)qty * unit; grand += lineTotal; count++;
Utf8.TryWrite(dest.AsSpan(pos), $"{orderId}|{menu} x{qty}|{lineTotal / 100}.{lineTotal % 100:D2}\n", out int written); pos += written; }
Utf8.TryWrite(dest.AsSpan(pos), $"TOTAL|{count}|{grand / 100}.{grand % 100:D2}\n", out int tw); return pos + tw;}| input ที่วัด | อุ่น 200 รอบ (tier-0) | อุ่น 2,000,000 รอบ (tier-1) |
|---|---|---|
RenderV4(Batch.Lines, dest) บน batch 20 บรรทัดชุดเดิม | 1992 B | 0 B |
byte ที่โผล่มาที่ tier-0 ไม่ได้ลึกลับ มันคือช่องของ interpolated string ที่ถูก box ช่องละ 24 byte สำหรับ int / long / double / DateTime และ 32 byte สำหรับ Guid / decimal ซึ่งตรงกับ control ที่ box ด้วยมือเป๊ะ
หลักฐานว่ามันเป็นเรื่องของ tier ไม่ใช่เรื่องของ API มาจากสองทาง ทางแรก DOTNET_JitDisasmSummary รายงานว่าที่ 10,000 calls method นี้ยังเป็น Instrumented Tier0 ส่วนที่ 500,000 calls กลายเป็น Tier1 with Synthesized PGO แล้ว byte หายหมด ทางที่สอง การตั้ง DOTNET_TieredCompilation=0 ให้ 0 B ตั้งแต่รอบแรก
ห้ามจำเลข 500,000 calls เป็นเกณฑ์ มันคือจุดที่สังเกตได้บนเครื่องนี้ ไม่ใช่ threshold ทางการของ tier-1
boxing แพงกว่าเสมอจริงหรือ — วัดแล้วกลับด้าน
หัวข้อที่มีชื่อว่า “boxing แพงกว่าเสมอจริงหรือ — วัดแล้วกลับด้าน”เกร็ดที่หักล้าง folklore ที่คอร์สนี้เองเกือบเขียนลงไป และบทเรียนจริงที่อยู่ใต้มัน
OrderLine ใน domain นี้เป็น readonly struct ที่มี int + reference ของ string + int + int รวม 24 byte ส่วน OrderLine ที่ถูก box หนึ่งตัววัดได้ 40 byte ตัวเลขคู่นี้ทำให้ทุกคนสรุปทันทีว่าเก็บ struct ตรงๆ ต้องถูกกว่าเก็บผ่าน interface แน่นอน
| input ที่วัด | byte |
|---|---|
new List<IOrderLine>() แล้วใส่ 20 ตัว (box ทุกตัว) | 1408 B |
new List<OrderLine>() แล้วใส่ 20 ตัว (ไม่ box) | 1568 B |
new List<IOrderLine>(20) แล้วใส่ 20 ตัว | 1016 B |
new List<OrderLine>(20) แล้วใส่ 20 ตัว | 536 B |
new OrderLine[20] | 504 B |
แบบที่ box อ่านได้ น้อยกว่า แบบที่ไม่ box อยู่ 160 byte เหตุผลไม่ได้เกี่ยวกับ boxing เลย มันเกี่ยวกับสองอย่างที่คูณกัน คือ reference ตัวหนึ่งกิน 8 byte ขณะที่ struct ตัวหนึ่งกิน 24 byte และ List<T> โตแบบเท่าตัวคือ 4 → 8 → 16 → 32 แปลว่าอาร์เรย์ภายในถูกสร้างทิ้งสร้างใหม่หลายรอบ และอาร์เรย์ที่ถูกทิ้งระหว่างทางของฝั่ง struct จ่ายช่องละ 24 byte ส่วนของฝั่ง reference จ่ายช่องละ 8 byte
พอใส่ capacity ลงไป ลำดับก็กลับด้านทันที คือ 1016 เทียบ 536 byte และอาร์เรย์ล้วนที่ไม่ต้องโตเลยถูกที่สุดที่ 504 byte
บทเรียนจริงจึงไม่ใช่ “เลี่ยง boxing” แต่คือ “ใส่ capacity ให้ collection ที่รู้ขนาดล่วงหน้า” ซึ่งเป็นข้อสรุปเดียวกับที่บท 2 เจอตอนวัด List ของ struct ที่ไม่ตั้ง capacity ไว้
กับดัก UTF-8 ภาษาไทย — จำนวน char ไม่เท่ากับจำนวน byte
หัวข้อที่มีชื่อว่า “กับดัก UTF-8 ภาษาไทย — จำนวน char ไม่เท่ากับจำนวน byte”ข้อเท็จจริงที่ทำให้ buffer ที่จองตามจำนวน char เล็กเกินไปสามเท่าสำหรับใบเสร็จภาษาไทย
code ฝั่ง v3 และ v4 ต้องจอง buffer เอง และการจองตามจำนวน char คือกับดักที่จะไม่มีอะไรเตือน จนกว่าจะมีเมนูภาษาไทยเข้ามา
using System.Text;
foreach (string s in new[] { "ต้มยำกุ้ง", "Tom Yum Kung", "ข้าวเหนียวมะม่วง" }) Console.WriteLine($"{s}: chars={s.Length} " + $"utf8={Encoding.UTF8.GetByteCount(s)} " + $"max={Encoding.UTF8.GetMaxByteCount(s.Length)}");
// อันตราย: Span<byte> buf = stackalloc byte[line.Length]; // เล็กไป 3 เท่าสำหรับภาษาไทย// ถูกต้อง : Span<byte> buf = stackalloc byte[Encoding.UTF8.GetMaxByteCount(line.Length)];| input ที่วัด | chars | UTF-8 byte | GetMaxByteCount(chars) |
|---|---|---|---|
"ต้มยำกุ้ง" | 9 | 27 | 30 |
"Tom Yum Kung" | 12 | 12 | 39 |
"ข้าวเหนียวมะม่วง" | 16 | 48 | 51 |
| batch ทั้งชุด 20 บรรทัด | 464 | 894 | — |
ทั้ง batch ขยายจาก 464 chars เป็น 894 byte คิดเป็นอัตราส่วน 1.93 เท่า ส่วน string อังกฤษล้วนอยู่ที่หนึ่งต่อหนึ่ง กับดักนี้จึงเป็นกับดักที่ผ่านเทสในภาษาอังกฤษได้สบายๆ แล้วไปพังกับข้อมูลจริง
สังเกตอีกอย่างที่มีประโยชน์คือ GetMaxByteCount ให้ค่าที่ปลอดภัยแต่เผื่อเยอะมากสำหรับข้อความอังกฤษ คือขอ 39 byte สำหรับข้อความที่ใช้จริง 12 byte ราคาของความถูกต้องคือพื้นที่ที่จองเกิน ซึ่งบน stack ก็ยังคุ้มกว่าการเดา
1560 byte ที่เหลือใน v3 ไม่ได้มาจาก pool
หัวข้อที่มีชื่อว่า “1560 byte ที่เหลือใน v3 ไม่ได้มาจาก pool”ราคาของ ArrayPool ครั้งแรกเทียบครั้งต่อไป และการชี้ตัวจริงที่กิน byte ที่เหลือในขั้นก่อนสุดท้าย
ความเชื่อที่เจอบ่อยคือ ArrayPool มีค่าใช้จ่ายทุกครั้งที่ Rent จึงควรเลี่ยงไป stackalloc แทน ข้อมูลบอกคนละอย่าง
using System.Buffers;
// บน thread ที่เพิ่งสร้าง (cold)long b = GC.GetAllocatedBytesForCurrentThread();char[] a = ArrayPool<char>.Shared.Rent(256);long c = GC.GetAllocatedBytesForCurrentThread(); // c - b = 672 BArrayPool<char>.Shared.Return(a);
// รอบต่อๆ ไปบน thread เดิมfor (int i = 0; i < 1000; i++){ char[] x = ArrayPool<char>.Shared.Rent(256); ArrayPool<char>.Shared.Return(x); // เฉลี่ย 0.00 B ต่อรอบ}| input ที่วัด | byte |
|---|---|
Rent(256) ครั้งแรกบน thread ที่เพิ่งสร้าง | 672 B |
Rent(256) + Return 1,000 รอบบน thread เดิม | 0.00 B ต่อรอบ |
ค่า 672 byte นั้นคงที่ วัดบน thread ใหม่2 thread ได้ 672 ทั้งคู่ และมันเป็นราคาครั้งเดียวของการตั้งคลังต่อ thread ไม่ใช่ราคาต่อการยืม
ผลที่ตามมาสำคัญกว่าตัวเลข 1560 byte ที่ยังเหลืออยู่ใน v3 ไม่ได้มาจาก pool เลยสัก byte มันมาจาก byte[] 21 ก้อนที่ Encoding.UTF8.GetBytes คืนมาต่างหาก ซึ่งเป็นเหตุผลว่าทำไมขั้นถัดไปถึงต้องเปลี่ยนไปเข้ารหัสจาก span ไปยัง span โดยตรง แทนที่จะไปปรับจูน pool ให้ดีขึ้น
นี่คือตัวอย่างว่าทำไมการวัดถึงต้องมาก่อนการแก้ ถ้าเดาว่า pool คือตัวที่เหลืออยู่ ก็จะไปเสียเวลาปรับสิ่งที่วัดได้ 0 อยู่แล้ว
ตอนจบที่ซื่อสัตย์ — allocation หายหมด เวลาได้กลับมาราวสี่เท่าบนเครื่องนี้
หัวข้อที่มีชื่อว่า “ตอนจบที่ซื่อสัตย์ — allocation หายหมด เวลาได้กลับมาราวสี่เท่าบนเครื่องนี้”สิ่งที่ BenchmarkDotNet ยืนยันให้ได้ และสิ่งที่มันยืนยันให้ไม่ได้ในการรันครั้งเดียว
บันไดทั้งบทถูก cross-check ด้วย BenchmarkDotNet บนเครื่องเดิม และ column Allocated ตรงกับตัวนับต่อ thread ทุกบรรทัด
| แถวของ BDN | input ที่วัด | Allocated |
|---|---|---|
| v0 naive | Batch.Lines 20 บรรทัด → _dest เป็น byte[] 8192 byte | 12272 B |
| v1 | input ชุดเดียวกันทุก byte | 9880 B |
| v2 | input ชุดเดียวกันทุก byte | 6048 B |
| v3 | input ชุดเดียวกันทุก byte | 1560 B |
| v4 no-alloc format | input ชุดเดียวกันทุก byte | 0 B |
v4-alt Utf8.TryWrite | input ชุดเดียวกันทุก byte | 0 B |
| v4-alt pooled scratch | input ชุดเดียวกันทุก byte | 0 B |
สองแถวสุดท้ายมีค่าในตัวเอง คือทางเลือกที่ใช้ Utf8.TryWrite และทางเลือกที่ยืม buffer จาก pool แทน stackalloc ต่างก็ไปถึง 0 byte ได้เหมือนกัน แปลว่าปลายทางนี้ไม่ได้มีทางเดียว
ส่วน column เวลา บทนี้ ไม่ชิป และเหตุผลต้องพูดให้ครบ
- BenchmarkDotNet ถูกรันเพียง ครั้งเดียว (610 วินาที) ตัวเลขเวลาจึงไม่เคยผ่านเกณฑ์ reproduced ของคอร์สนี้เลย ต่างจาก column Allocated ที่ถูกยืนยันซ้ำด้วยการวัดอิสระคนละเครื่องมือและคนละ process
- BDN เองรายงานว่าแถว v0 เป็น bimodal (mValue 3.24) และแถว v2 กับ v4-alt เป็น multimodal (2.93 และ 3.2) พร้อมตัด outlier ออก 2 ถึง 9 ตัวต่อ benchmark
- เครื่องที่วัดคือ WSL2 บน 4 physical core ที่ไม่ได้กันงานอื่นออกไป
สิ่งที่พูดได้จึงมีแค่ประโยคเดียว “เร็วขึ้นราวสี่เท่าบนเครื่องนี้” และคอร์สนี้ประกาศตรงนี้เลยว่า ประโยคนั้นเป็นการเล่าให้ฟัง ไม่ใช่ข้อสรุปที่บทนี้อ้างอิงเป็นหลักฐาน ตัวเลขเวลาที่แม่นกว่านั้น เช่นอัตราส่วนที่มีทศนิยม ไม่มีอยู่ในบทนี้เพราะมันไม่ควรมี
บันไดนี้ตัด allocation จาก 12272 byte ต่อ batch ลงเหลือ 0 byte นั่นคือ ทั้งหมด และวัดซ้ำได้
สิ่งที่ตามมาด้วยกลับมีแค่ “ราวสี่เท่าบนเครื่องนี้” ซึ่งเป็นตัวเลขที่บทนี้เองไม่นับเป็นหลักฐาน ถ้าคุณกำลังเถียงกับทีมว่าควรลง refactor แบบนี้ไหม ตัวเลขที่คุณถืออยู่คือ byte ไม่ใช่เวลา และการเอา byte ไปขายเป็นเวลาคือสิ่งที่คอร์สนี้ห้ามไว้ตั้งแต่บทแรก
ย้ำอีกครั้งว่า allocation ไม่เท่ากับ residency คอร์สนี้นับ byte ที่ จัดสรร ไม่ได้นับ byte ที่ค้างอยู่ และไม่มีข้อมูล GC pause, throughput, tail latency หรือ working set แม้แต่ตัวเดียวตลอดทั้งแปดบท
อันไหนที่ผมจะไม่เขียนแบบนั้นใน code ปกติ
หัวข้อที่มีชื่อว่า “อันไหนที่ผมจะไม่เขียนแบบนั้นใน code ปกติ”การอ่านบันไดเดิมด้วยเกณฑ์คนละอัน คือความอ่านง่ายกับความเสี่ยง ไม่ใช่จำนวน byte
บันไดนี้เขียนขึ้นเพื่อพิสูจน์ว่าไปถึง 0 ได้ ไม่ใช่เพื่อเสนอว่า code ทุกที่ควรหน้าตาแบบนี้ ถ้าถามว่าผมจะเอาขั้นไหนไปใช้จริงบ้าง คำตอบแบ่งได้ชัดเจน
v1 กับ v2 ทำเถอะ — v2 มีจำนวนบรรทัดเท่ากับ v0 คือ 30 บรรทัด และ v1 สั้นกว่า v0 ด้วยซ้ำ คือ 22 เทียบ 30 บรรทัด สองขั้นนี้จึงไม่ได้แลกอะไรเลย ได้ byte กลับมาโดยไม่เสียความอ่านง่าย และไม่เพิ่มสิ่งที่ต้องจำตอนแก้ bug ตอนตีสาม
v3 กับ v4 ต้องมีเหตุผลรองรับก่อน ราคาของสองขั้นนี้เขียนออกมาเป็นรายการได้
- helper ที่ต้องเขียนเอง 41 บรรทัด คือ
ParseLine11 บรรทัด,WriteReceiptChars12 บรรทัด,WriteTotalChars9 บรรทัด และWriteMoney9 บรรทัด — code ที่ต้องมีคนดูแลและต้องมีเทสของตัวเอง try/finallyรอบการยืมคืน buffer และความเสี่ยงว่าจะมีใครใช้อาร์เรย์ต่อหลังคืนไปแล้ว ซึ่งเป็น bug ที่เงียบและตามยากWriteMoneyใช้สำนวน(char)('0' + cc / 10)ซึ่ง compiler ตรวจ off-by-one ให้ไม่ได้ ผิดหนึ่งช่องแล้วใบเสร็จออกมาเป็นตัวเลขที่ผิดโดยไม่มีอะไร throw
เกณฑ์ที่ผมใช้ตัดสินจึงไม่ใช่ “ประหยัดได้กี่ byte” แต่คือ เส้นนี้ถูกเดินซ้ำมากพอที่ราคาต่อรอบจะกลายเป็นราคาของระบบหรือยัง ถ้าใช่ v3 กับ v4 คุ้ม ถ้าไม่ใช่ สิ่งที่คุณเพิ่งซื้อมาคือหนี้ที่ต้องจ่ายทุกครั้งที่มีคนแก้ code นี้
ขีดจำกัดที่ต้องเขียนตรงๆ
หัวข้อที่มีชื่อว่า “ขีดจำกัดที่ต้องเขียนตรงๆ”สิ่งที่ข้อมูลของบทนี้และของทั้งคอร์สยังพิสูจน์ไม่ได้ และจะไม่ถูกอ้างที่ไหนอีก
- บทนี้ไม่ได้พิสูจน์ว่าเร็วขึ้น ตัดสินด้วย byte ไม่ใช่นาโนวินาที ตัวเลขเวลาของ capstone มาจากการรัน BDN ครั้งเดียวและมีการกระจายแบบ bimodal จึงพูดได้แค่ “ราวสี่เท่าบนเครื่องนี้” ในฐานะเรื่องเล่า
- 0 byte ไม่ได้แปลว่าไม่มีค่าใช้จ่าย GC เลยในระบบจริง buffer ปลายทาง
byte[]8192 byte และอาร์เรย์string[]ที่เป็น input ถูกจัดสรรไว้ นอก ช่วงที่วัด - บันไดนี้ generalise ไปงานอื่นไม่ได้ ทุกตัวเลขวัดบน batch 20 บรรทัด ที่ให้ผลลัพธ์ 982 byte เท่านั้น
- ตัวเลขทุกตัวผูกกับ x64 และ .NET 10.0.10 object header 16 byte, reference 8 byte, boxed
OrderLine40 byte — บนสถาปัตยกรรมอื่นชุดตัวเลขนี้เปลี่ยน และ layout ของ class ไม่ใช่สัญญาที่ runtime รับประกัน Utf8.TryWriteไม่ใช่ API ที่จอง ในสภาวะอุ่นแล้วมันคือ 0 byte เลข 1992 B เป็น artefact ของการวัดที่ tier-0 ล้วนๆGC.GetAllocatedBytesForCurrentThreadไม่ใช่เครื่องมือ benchmark มันวัด allocation ไม่ใช่เวลา และมันวัด code ของ JIT tier ที่เราบังเอิญอยู่ โดยไม่มีสัญญาณเตือนใดๆ- คอร์สนี้มีผลลบต่อสมมติฐานของตัวเองอยู่ในเนื้อ บท 5 วัดแล้วไม่เห็น stack allocation เลยสักรูปแบบเดียว ขณะที่บท 1, 2, 3 และ 8 เห็นชัด สิ่งที่เขียนได้คือ “วัดบนเครื่องนี้ไม่เห็น” ไม่ใช่ “ไม่มี”
- หลายข้อสรุปเป็น implementation detail ไม่ใช่สัญญา เช่นการที่
List<T>โตแบบ 4 → 8 → 16 → 32 หรือจุดที่ JIT เลื่อนขั้นบนเครื่องนี้ ห้ามออกแบบ code ให้พึ่งพาโดยตรง - ไม่ได้วัด .NET 9 หรือเก่ากว่าเลย จึงห้ามพูดว่าเรื่องใดในคอร์สนี้ “ดีขึ้นจาก version ก่อน”
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”สิ่งที่ capstone นี้พิสูจน์ได้ครบ สิ่งที่มันปฏิเสธจะพิสูจน์ และทางไปต่อของแต่ละเรื่องที่คอร์สนี้ไม่รับ
สิ่งที่บทนี้วัดไว้ให้ครบแล้ว
- บันได 5 ขั้นบน hot path เส้นเดียว 12272 → 9880 → 6048 → 1560 → 0 byte ต่อ batch โดยผ่านประตูความถูกต้องที่ 982 byte ครบทั้ง7 version
- บิลของ v0 แยกเป็นก้อน โดยผู้ร้ายอันดับหนึ่งคือ
Splitที่ 3832 byte ไม่ใช่ boxing ที่ 1408 byte - ขั้นที่คุ้มที่สุดคือ v2 ซึ่งตัด 3832 byte ด้วยจำนวนบรรทัด code เท่าเดิม
- 12312 byte ที่ทำซ้ำได้เป๊ะสามครั้งข้าม process เทียบกับค่าจริง 12272 byte ต่างกัน 40 byte เพราะ
TieredPGOถูกปิดไว้ใน file วัดผล Utf8.TryWriteอ่านได้ 1992 byte ที่ tier-0 และ 0 byte ที่ tier-1 จากไบนารีตัวเดียวกันList<IOrderLine>ที่ box อ่านได้ 1408 byte น้อยกว่าList<OrderLine>ที่ไม่ box ซึ่งอ่านได้ 1568 byte- ใบเสร็จภาษาไทยขยายจาก 464 chars เป็น 894 byte คิดเป็น 1.93 เท่า
- ArrayPool จ่าย 672 byte ครั้งเดียวต่อ thread ใหม่ แล้ว 0.00 byte ต่อรอบหลังจากนั้น
เส้นขอบเขตเดิมที่บท 1 ประกาศไว้ ยังยืนอยู่ครบทั้งสี่ข้อ ตัดสินด้วย column Allocated ไม่ใช่ Mean · ตัวเลขเวลาต้องพ่วงเครื่องและค่าการกระจาย พ่วงไม่ได้ก็ตัดทิ้ง · byte ที่ประหยัดได้ไม่เท่ากับเวลาที่ประหยัดได้ · และ “รันสองรอบได้เท่ากัน” ยังไม่พอ
จบคอร์สแล้ว เรื่องที่คอร์สนี้ตั้งใจไม่รับ มีที่ไปของมันอยู่
- ไวยากรณ์ C# 11–14 ที่ code ทั้งคอร์สใช้โดยไม่อธิบาย → C# 8 → 14: อะไรเปลี่ยนไปบ้าง 🔁
- GC pause จริง throughput จริง และการมองเห็นระบบตอนรัน → คอร์ส #19 production-observability 🔁 คอร์สนี้วัด byte คอร์สนั้นวัดระบบ
- ตัว domain Order เอง ตั้งแต่ model จนถึงการแยก bounded context → คอร์ส #8 ถึง #11 🔁
- ledger กับการลงบัญชี ที่ยอดเงินในใบเสร็จนี้อ้างถึง → คอร์ส #10 payments-ledger-ddd 🔁
ประโยคสุดท้ายของคอร์สนี้เป็นประโยคเดียวกับประโยคแรก byte คือหลักฐาน นาโนวินาทีไม่ใช่ และตัวเลขที่ทำซ้ำได้ ก็ยังต้องถูกถามว่ามันวัดอะไรอยู่กันแน่
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- GC.GetAllocatedBytesForCurrentThread Method (System) — Microsoft Learn (ms.date 2025-07-01, อัปเดต 2026-07-01) — ตัวนับสะสมต่อ thread ที่บันไดทั้งบทอ่านค่าจากมัน และนิยามว่ามันนับ byte ที่ จัดสรร ตลอดอายุของ thread ไม่ใช่ byte ที่รอดจาก GC
- BenchmarkDotNet — Diagnosers (ดึงข้อมูล 2026-08-07, BDN v0.15.x) —
[MemoryDiagnoser]ที่ผลิต column Allocated ซึ่งเป็น column เดียวของตาราง capstone ที่บทนี้ยอมชิป - Compilation config settings — Microsoft Learn (ms.date 2021-10-29, อัปเดต 2025-12-03) —
TieredCompilation,TieredPGOและTC_QuickJitซึ่งเป็นสวิตช์ที่ทำให้ v0 อ่านได้ 12272 หรือ 12312 byte จากไบนารีตัวเดียวกัน - Performance Improvements in .NET 10 — .NET Blog (2025-09-10) — escape analysis กับ object stack allocation ที่เป็นเหตุผลว่าทำไม enumerator ที่ถูก box 40 byte ถึงหายไปเมื่อ PGO เปิดอยู่
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3ทีมของคุณวัด hot path ตัวหนึ่งได้ 12312 byte ต่อ batch เท่ากันเป๊ะสามครั้งติดที่ warm-up 100000, 200000 และ 300000 รอบ และเท่ากันข้าม process ด้วย ควรสรุปว่าอย่างไร