boxing: ตำนานที่ตายแล้ว และตัวจริงที่ยังซ่อนอยู่
คำเตือนเรื่อง boxing ที่ท่องกันมาสิบปีมีอยู่ประมาณนี้ string.Format จอง object[] ทุกครั้ง · Enum.HasFlag box สองก้อนต่อการเรียกหนึ่งครั้ง · การใส่ค่าลงช่องของ $"..." box ทุกช่อง
บทนี้เอาทั้งสามข้อไปวัดบน .NET 10.0.10 แล้วได้คำตอบว่า ไม่มีข้อไหนจริงในรูปแบบที่ท่องกันมา สองข้อแรกเล็งผิดเป้าไปเลย ส่วนข้อ Enum.HasFlag จริงเป๊ะตอนที่ code ยังไม่ถูกเลื่อนขั้น แล้วกลายเป็นศูนย์ทันทีที่ JIT เลื่อนขั้นให้ ส่วนที่เสียจริงยังเสียอยู่ แต่มันเสียคนละที่กับที่คำเตือนชี้
และในเวลาเดียวกัน boxing ตัวจริงที่ยังเหลืออยู่ในปี 2026 ก็ซ่อนอยู่ในที่ที่แทบไม่มีใครมองคือ parameter ที่ประกาศเป็น interface, enumerator ที่ลอดผ่าน IEnumerable<T> และ struct ที่ลืม implement IEquatable<T>
boxingboxingการห่อค่าของ value type ไว้ใน object บน heap (หัว 16 byte + payload ปัดขึ้นทีละ 8) ซึ่งเป็นคุณสมบัติของ code ที่ JIT ปล่อยออกมา ไม่ใช่ของซอร์ส คือการห่อค่าของ value type ไว้ใน object บน heap และประโยคที่ทั้งบทนี้ตั้งอยู่บนมันคือ boxing เป็นคุณสมบัติของ code ที่ JIT ปล่อยออกมา ไม่ใช่ของซอร์สที่คุณอ่าน ซอร์สบรรทัดเดียวกันให้ตัวเลขได้สองค่า ขึ้นกับว่า JIT compile มันถึงขั้นไหนแล้ว
ทั้งคอร์สเดินบน hot path เส้นเดียวของ domain Order ที่คอร์ส #8–#11 สร้างไว้ คือ parse บรรทัดออร์เดอร์ → รวมยอด → พิมพ์ใบเสร็จ (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 · workstation non-concurrent GC · -c Release และผูกกับ x64 ที่ IntPtr = 8 byte ทั้งหมด
ไวยากรณ์ C# 11–14 ที่ใช้ในบทนี้ (primary constructor บน struct, collection expression = [], params ReadOnlySpan<T>) คอร์สนี้ ใช้ โดยไม่หยุดอธิบาย ถ้าเจอของที่ไม่คุ้นให้เปิด C# 8 → 14: อะไรเปลี่ยนไปบ้าง ควบคู่ไป
box หนึ่งก้อนมีขนาดที่วัดได้ ไม่ใช่คำอุปมา
หัวข้อที่มีชื่อว่า “box หนึ่งก้อนมีขนาดที่วัดได้ ไม่ใช่คำอุปมา”ขนาดของ box ที่วัดจริงบนเครื่องนี้ ซึ่งเป็นฐานที่ใช้ถอดรหัสตัวเลขอื่นทุกตัวในบท
ก่อนจะเถียงว่าอะไร box หรือไม่ box ต้องรู้ก่อนว่าถ้ามันเกิดขึ้น มันกินกี่ byte วิธีวัดคือจอดค่าลง static field แล้วอ่านเดลตา
// ต้องจอดของไว้ที่ static field มิฉะนั้น escape analysis ของ .NET 10 จะย้าย box// ไปไว้บน stack แล้วเดลตาหายไปเงียบๆ โดยไม่มี error ให้เห็นstatic object? _sink;
static long Box<T>(T v) where T : struct{ for (int i = 0; i < 200; i++) _sink = v; // อุ่นเครื่อง long before = GC.GetAllocatedBytesForCurrentThread(); _sink = v; return GC.GetAllocatedBytesForCurrentThread() - before;}| input ที่วัด | Unsafe.SizeOf<T>() | ขนาด box |
|---|---|---|
Box(new OrderLine(2, 18000)) — struct 2 int | 8 B | 24 B |
Box(new MenuKeyWithName(1042, "ผัดไทยกุ้งสด")) — int + ref | 16 B | 32 B |
Box(new OrderLineFull(...)) — 3 int + 1 ref | 24 B | 40 B |
Box(18000) — int | – | 24 B |
Box(OrderStatus.Cooking) — enum ฐาน int | – | 24 B |
รูปแบบที่ วัดได้ (ไม่ได้อนุมานจากสูตร) คือหัว 16 byte บวก payload ที่ปัดขึ้นทีละ 8 byte
ขนาดอ้างอิงชุดถัดไปวัดคู่กันใน process เดียวกัน เพื่อให้การถอดรหัสตัวเลขในหัวข้อหลังๆ เป็น การวัด ไม่ใช่ การคำนวณ
| input ที่วัด | byte |
|---|---|
new object[2] | 40 B |
new object[3] | 48 B |
new object[4] | 56 B |
new object[6] | 72 B |
new string('x', 5) | 32 B |
new string('x', 10) | 48 B |
new string('x', 11) | 48 B |
new string('x', 21) | 64 B |
new string('x', 24) | 72 B |
new string('x', 28) | 80 B |
new string('x', 31) | 88 B |
process เดียวกันยังยืนยันด้วยว่า escape analysisescape analysisการที่ JIT พิสูจน์ว่า object ไม่หลุดออกนอก method แล้วย้ายมันไปวางบน stack แทน heap โดยไม่มี warning ใด ๆ มีแต่ตัวเลขที่หายไปเงียบ ๆ มีอยู่จริงบนเครื่องนี้ และมันจะกลืนตัวเลขที่คุณตั้งใจวัดไปเงียบๆ
| input ที่วัด | byte |
|---|---|
object o = satang; IntSink = (int)o; — ไม่หลุด ไม่มี loop | 0 B |
บรรทัดเดียวกัน แต่จอด o ลง static field | 24 B |
นี่คือเหตุผลที่ทุก snippet วัดผลในคอร์สนี้มี static object? _sink; พร้อมคอมเมนต์กำกับ ตามที่บท 1 ตั้งเป็นวินัยไว้แล้ว
ฉากหลัก parameter เป็น interface เทียบ generic constraint
หัวข้อที่มีชื่อว่า “ฉากหลัก parameter เป็น interface เทียบ generic constraint”method สองตัวที่เนื้อในเหมือนกันทุกตัวอักษร ให้ผลลัพธ์เชิงตัวเลขเท่ากัน แต่อ่านเดลตาได้ 144 B เทียบ 0 B
using System.Runtime.CompilerServices;
public interface IPricedLine { int LineTotalSatang { get; } }
public readonly struct OrderLine(int qty, int unitSatang) : IPricedLine{ public int Qty { get; } = qty; public int UnitSatang { get; } = unitSatang; public int LineTotalSatang => Qty * UnitSatang;}
public static class Bench{ static readonly OrderLine[] Lines = [ new(2, 18000), new(1, 22000), new(3, 13500), new(1, 8000), new(2, 17000), new(4, 9000), ];
// เสีย 144 B: struct ถูก box ตอนแปลงเป็น IPricedLine [MethodImpl(MethodImplOptions.NoInlining)] static int TotalViaInterface(IPricedLine line) => line.LineTotalSatang;
// เสีย 0 B: generic ถูก specialize ต่อ value type, callvirt กลายเป็น constrained call [MethodImpl(MethodImplOptions.NoInlining)] static int TotalViaGeneric<T>(T line) where T : IPricedLine => line.LineTotalSatang;
public static int Alloc() { int s = 0; foreach (OrderLine l in Lines) s += TotalViaInterface(l); return s; } public static int Zero() { int s = 0; foreach (OrderLine l in Lines) s += TotalViaGeneric(l); return s; }}| input ที่วัด | version | เครื่องวัดที่เขียนเอง | BDN column Allocated | ผลลัพธ์ที่คืนมา |
|---|---|---|---|---|
ออเดอร์ 6 บรรทัด [(2,18000) (1,22000) (3,13500) (1,8000) (2,17000) (4,9000)] | TotalViaInterface(IPricedLine) | 144 B | 144 B | 176500 |
| input เดียวกันเป๊ะ | TotalViaGeneric<T> | 0 B | – (ศูนย์) | 176500 |
ถอดรหัส 144 B จากชิ้นส่วนที่วัดแยกไว้แล้วในหัวข้อก่อน คือ box ของ OrderLine ซึ่ง payload 8 byte วัดได้ 24 B ต่อก้อน คูณด้วยหกบรรทัดของ input ถ้าเปลี่ยนเป็น struct ที่ใหญ่ขึ้น ราคาต่อก้อนก็ขยับตามตารางขนาด box ข้างบนทันที
generic constraintgeneric constraintการผูก parameter ชนิดด้วย `where T : …` เพื่อให้ JIT สร้าง code เฉพาะชนิดนั้น แทนที่จะรับเป็น interface แล้ว box ทุกตัวที่ส่งเข้าไป ลบตัวเลขนั้นทิ้งได้เพราะ JIT สร้าง code หนึ่งชุดต่อ1 value type ที่เอาไปใช้จริง การเรียกผ่าน interface จึงกลายเป็นการเรียกตรงบนค่าที่ยังอยู่ที่เดิม ไม่ต้องยกขึ้น heap ก่อน
กรณีนี้ไม่ไวต่อ tier วัดได้ 144 B เท่ากันทั้งที่ tier-0 และ tier-1 และในการทดสอบการลู่เข้าที่รันแปดรอบติดกัน ตัวเลขนี้อ่านได้ 144 B ทุกรอบตั้งแต่รอบแรกถึงรอบสุดท้าย ไม่เคยขยับ
เทียบเลนหน่วยความจำ
6 / 6 ออเดอร์.NET 10.0.10 · X64บทที่ 3 · มี method คิดยอดต่อบรรทัด รับ struct เข้าไป — จะประกาศ parameter เป็นชนิดอะไร
แกนของกล่องนี้คือ จำนวนออเดอร์ที่ประมวลผลไปแล้ว ไม่ใช่เวลา — จังหวะของปุ่ม “เดินอัตโนมัติ” เป็นค่าคงที่ที่ตั้งไว้เพื่อให้ดูทัน ไม่ได้แทนความเร็วจริงของ code และหน้านี้ไม่แสดงเวลาที่ไหนเลย
ออเดอร์ล่าสุดที่ป้อนเข้าไป: 1047,ชาไทยเย็น,4,9000
code เดิม รับเป็น interface
struct ถูกห่อเป็นกล่องบน heap ทุกครั้งที่เรียก
code ที่ถูกวัด
public static int TotalViaInterface(IPricedLine line) => line.LineTotalSatang;
144byte ที่จองบน heap
6 ก้อน · กล่อง (box) 6 · ออเดอร์ล่าสุดเพิ่ม +24 B
heap หลังผ่านไป 6 ออเดอร์
code ใหม่ generic constraint
JIT สร้าง code เฉพาะชนิดนั้น เรียกตรงไม่ต้องห่อ
code ที่ถูกวัด
public static int TotalViaGeneric<T>(T line) where T : IPricedLine => line.LineTotalSatang;
0byte ที่จองบน heap
ไม่มีก้อนไหนถูกจองเลย · ออเดอร์ล่าสุดเพิ่ม 0 B
heap หลังผ่านไป 6 ออเดอร์
heap ว่าง — 0 ก้อน · 0 byte
ก้อนพวกนี้ ตายเกือบทันที หลังถูกจอง ราคาที่จ่ายจึงเป็น GC pressure กับ cache traffic ไม่ใช่ RAM ที่ถูกยึดค้างไว้ — กล่องนี้วัด allocation ไม่ใช่ residency
หนึ่งช่องบนตาราง heap = 8 byte (ความละเอียดจริงของ heap บน X64) · ▦ อาร์เรย์ · ▨ string · ▩ กล่อง
code สองบรรทัดนี้หน้าตาแทบเหมือนกันและให้ผลเท่ากันเป๊ะ ต่างกันแค่คำว่า IPricedLine กับ T — แต่บรรทัดบนสร้างกล่องขนาด 24 byte ทุกครั้งที่เรียก เพราะ interface ต้องการอ้างอิงไปยัง object runtime จึงคัดลอก struct ขึ้น heap ให้ก่อน ส่วนบรรทัดล่าง compiler รู้ชนิดจริงตั้งแต่ตอน JIT จึงเรียก method ตรงบนค่าที่อยู่บน stack
ราคาที่จ่าย generic ทำให้ JIT สร้าง code หนึ่งชุดต่อหนึ่ง struct ที่เอาไปใช้ — ประหยัด heap แต่จ่ายเป็นขนาด code และเวลา JIT ถ้ามี struct สิบชนิดก็สิบชุด
ตัวเลขที่วัดได้ทั้งหมดของฉากนี้ (byte)
| ออเดอร์ | รับเป็น interface | generic constraint | ก้อนที่เกิดขึ้น |
|---|---|---|---|
| ผัดไทยกุ้งสด | 24 | 0 | ▩ box ของ OrderLine 24 B |
| ต้มยำกุ้ง | 24 | 0 | ▩ box ของ OrderLine 24 B |
| ข้าวมันไก่ | 24 | 0 | ▩ box ของ OrderLine 24 B |
| ส้มตำไทย | 24 | 0 | ▩ box ของ OrderLine 24 B |
| แกงเขียวหวานไก่ | 24 | 0 | ▩ box ของ OrderLine 24 B |
| ชาไทยเย็น | 24 | 0 | ▩ box ของ OrderLine 24 B |
| รวมหกออเดอร์ | 144 | 0 | ผลลัพธ์ที่คำนวณได้เท่ากันทั้งสองเลน (176,500) |
วนซ้ำหลายรอบแล้ววัดใหม่ทั้งหมด — ไม่ได้เอาเลขข้างบนไปคูณ
| จำนวนรอบ | 1 | 10 | 100 | 1,000 |
|---|---|---|---|---|
| รับเป็น interface | 144 | 1,440 | 14,400 | 144,000 |
| generic constraint | 0 | 0 | 0 | 0 |
GC.GetAllocatedBytesForCurrentThread() บน .NET 10.0.10 (X64) — ขนาดของแต่ละก้อนวัดจากการสร้าง object ชนิดเดียวกันจริง ๆ แล้ว script จะล้มถ้าผลรวมของก้อนไม่เท่ากับเดลตาที่วัดได้ ถ้าการจองไม่เป็นเส้นตรงตามจำนวน ออเดอร์ หรือถ้า code สอง version คำนวณผลออกมาไม่เท่ากัน · ไม่มีตัวเลขเวลาในกล่องนี้เลย เพราะจำนวน byte ที่จองซ้ำได้เป๊ะทุกรอบ แต่เวลาบนนาฬิกาไม่ซ้ำ · สร้างซ้ำได้ด้วย dotnet run -c Release --project scripts/alloc-lanes/AllocViz.csprojคำบรรยายภาพ: เลนซ้ายคือ version ที่รับ parameter เป็น interface เลนขวาคือ version generic constraint · หนึ่งขั้นบนแกนนอนคือหนึ่งบรรทัดออร์เดอร์ ไม่ใช่หนึ่งหน่วยเวลา · ทั้งสองเลนคิดยอดได้ 176500 เท่ากัน
BenchmarkDotNet วัดเวลาของ2 method นี้ไว้ด้วย และตัวเลขต้องอ่านพร้อมค่าการกระจายเสมอ InterfaceParam ได้ 110.27 ns (Error 9.339 ns, StdDev 27.39 ns) ส่วน GenericConstraint ได้ 24.25 ns (Error 3.856 ns, StdDev 11.19 ns) บนเครื่อง i7-7700 เครื่องเดียวกัน โดย BDN เตือน multimodal distribution กำกับมาด้วย
ตัวเลขเวลาคู่นี้ไม่ใช่ข้ออ้างของหัวข้อนี้ StdDev ของทั้งสองแถวใหญ่กว่าหนึ่งในสี่ของ mean และ BDN เองบอกว่าการกระจายมีหลายยอด สิ่งที่หัวข้อนี้อ้างคือ column byte ซึ่งเครื่องมือสองตัวคนละ process อ่านได้ตรงกัน
LINQ กับ enumerator ค่าใช้จ่ายอยู่ที่ตัวไล่ ไม่ใช่ที่ข้อมูล
หัวข้อที่มีชื่อว่า “LINQ กับ enumerator ค่าใช้จ่ายอยู่ที่ตัวไล่ ไม่ใช่ที่ข้อมูล”ลำดับเดียวกันหกสมาชิก ไล่ด้วยสี่วิธี ได้ 0 B บ้าง 40 B บ้าง โดยข้อมูลไม่เปลี่ยนเลย
var arr = new OrderLine[6]; // เติมออเดอร์ 6 บรรทัดชุดเดิมvar list = new List<OrderLine>(arr);IEnumerable<OrderLine> asSeq = list;
int a = 0; foreach (OrderLine l in list) a += l.LineTotalSatang; // 0 B (struct enumerator)int b = 0; foreach (OrderLine l in asSeq) b += l.LineTotalSatang; // 40 B (box ของ enumerator)int c = list.Sum(l => l.LineTotalSatang); // 40 B (เหตุผลเดียวกันเป๊ะ)
// เขียนมือ เลี่ยงทั้ง enumerator และ interfaceint d = 0;ReadOnlySpan<OrderLine> sp = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(list);for (int i = 0; i < sp.Length; i++) if (sp[i].Qty > 1) d += sp[i].LineTotalSatang; // 0 B| input ที่วัด | byte |
|---|---|
foreach บน OrderLine[6] | 0 B |
foreach บน List<OrderLine> 6 สมาชิก | 0 B |
foreach ผ่าน IEnumerable<OrderLine> ที่ตัวจริงเป็น List | 40 B |
foreach ผ่าน IEnumerable<OrderLine> ที่ตัวจริงเป็น array | 32 B |
list.Sum(l => l.LineTotalSatang) | 40 B |
list.Where(...).Sum(...) | 72 B |
arr.Where(...).Select(...).ToArray() | 144 B |
list.OrderByDescending(...).First() | 128 B |
list.Any(...) | 0 B |
for + CollectionsMarshal.AsSpan(list) | 0 B |
40 B ของแถวที่สามคือ box ของ List<T>.Enumerator ซึ่งเป็น struct ที่มี reference 8 + int 4 + int 4 + OrderLine 8 รวม payload 24 byte ตรงกับแถว “payload 24 byte = box 40 B” ในตารางขนาด box พอดี ส่วนแถวที่สี่ได้ 32 B เพราะตัวไล่ของ array คือ SZGenericArrayEnumerator ซึ่งเป็น class อยู่แล้ว ตัวเลขนั้นจึงเป็นการจอง object ตรงๆ ไม่ใช่ box
list.Sum(selector) ที่ได้ 40 B เท่ากันเป๊ะ จึงบอกได้ว่าราคาไม่ได้อยู่ที่ lambda แต่อยู่ที่การที่ LINQ รับ parameter เป็น IEnumerable<T> แล้วต้องเรียก GetEnumerator() ผ่าน interface
ตัวเลขทั้งตารางมาจากลำดับ 6 สมาชิก และเป็นต้นทุนตั้งต้นแบบคงที่ ไม่ใช่ต้นทุนต่อสมาชิก ห้ามคูณขึ้นไปเป็นลำดับหมื่นสมาชิก บทนี้ไม่ได้วัดขนาดอื่นเลย
params object[] และ collection ที่ไม่รู้จัก generic
หัวข้อที่มีชื่อว่า “params object[] และ collection ที่ไม่รู้จัก generic”สองที่ที่ boxing ยังเกิดตรงๆ ตามตำรา และตัวเลขที่บอกว่าฝั่งเขียนแพง ไม่ใช่ฝั่งอ่าน
[MethodImpl(MethodImplOptions.NoInlining)]static int LogParamsObject(string tag, params object[] args){ int n = tag.Length; foreach (object a in args) n += a.GetHashCode(); return n;}
[MethodImpl(MethodImplOptions.NoInlining)]static int LogGeneric<T1, T2, T3>(string tag, T1 a, T2 b, T3 c) => tag.Length + a!.GetHashCode() + b!.GetHashCode() + c!.GetHashCode();
[MethodImpl(MethodImplOptions.NoInlining)]static int LogParamsSpanInt(string tag, params ReadOnlySpan<int> args){ int n = tag.Length; foreach (int a in args) n += a; return n;}| input ที่วัด | byte |
|---|---|
LogParamsObject("order", 1042, 2, 18000) | 120 B |
LogGeneric("order", 1042, 2, 18000) | 0 B |
LogParamsSpanInt("order", 1042, 2, 18000) | 0 B |
params ReadOnlySpan<object> กับ argument ชุดเดียวกัน | 72 B |
LogParamsObject("order") — ไม่มี argument เลย | 0 B |
params object[] กับ string 3 ตัว | 48 B |
params object[] กับ int 6 ตัว | 216 B |
ถอดรหัสจากชิ้นส่วนที่วัดแยกไว้แล้ว 120 B ของแถวแรกคือ new object[3] 48 B บวก box ของ int 24 B สามก้อน ส่วนแถว ReadOnlySpan<object> ได้ 72 B เพราะ array หายไป แต่ box ยังอยู่ครบสามก้อน และแถว string 3 ตัวได้ 48 B เพราะมีแต่ array ไม่มี box เลย — string เป็น reference type อยู่แล้ว
แถว “ไม่มี argument เลย” ได้ 0 B เพราะ compiler ส่ง Array.Empty ที่ cache ไว้ ไม่ได้สร้าง array ใหม่
| input ที่วัด | byte |
|---|---|
new ArrayList(6) + Add ×6 | 248 B |
new List<OrderLine>(6) + Add ×6 | 104 B |
new Hashtable(6) + set ×6 | 648 B |
new Dictionary<int, OrderLine>(6) + set ×6 | 304 B |
อ่านออกจาก ArrayList พร้อม unbox ×6 | 0 B |
อ่านออกจาก List<OrderLine> ×6 | 0 B |
Hashtable lookup ด้วย key int ×6 | 144 B |
Dictionary<int, OrderLine> lookup ×6 | 0 B |
ตารางนี้หักล้างคำอธิบายที่ได้ยินบ่อยว่า “ArrayList แพงเพราะต้อง unbox ตอนอ่าน” — unbox ไม่ allocate เลย อ่านหกครั้งได้ 0 B ต้นทุนอยู่ฝั่งเขียนล้วนๆ
ข้อยกเว้นคือ Hashtable ที่ต้อง box key ทุกครั้งที่ค้น 144 B ของหกครั้งตรงกับ box ของ int 24 B หกก้อนตามตารางขนาด box
enum เป็น key และ struct ที่ลืม IEquatable<T>
หัวข้อที่มีชื่อว่า “enum เป็น key และ struct ที่ลืม IEquatable<T>”boxing ตัวจริงที่ยังเหลืออยู่ในปี 2026 ซ่อนอยู่ใน comparer ที่ runtime เลือกให้ ไม่ใช่ใน code ที่คุณเขียน
public enum OrderStatus { Placed, Cooking, Riding, Delivered }
// comparer ที่คน "เขียนเองแบบตรงไปตรงมา" — x.Equals(y) ผูกกับ ValueType.Equals(object)public sealed class NaiveStatusComparer : IEqualityComparer<OrderStatus>{ public bool Equals(OrderStatus x, OrderStatus y) => x.Equals(y); // box สองก้อนต่อครั้ง public int GetHashCode(OrderStatus o) => o.GetHashCode();}
public sealed class FastStatusComparer : IEqualityComparer<OrderStatus>{ public bool Equals(OrderStatus x, OrderStatus y) => (int)x == (int)y; // 0 B public int GetHashCode(OrderStatus o) => (int)o;}| input ที่วัด | byte |
|---|---|
Dictionary<OrderStatus, int> comparer ปริยาย lookup ×6 | 0 B |
Dictionary<OrderStatus, int>(new NaiveStatusComparer()) lookup ×6 | 288 B |
Dictionary<OrderStatus, int>(new FastStatusComparer()) lookup ×6 | 0 B |
Dictionary<Enum, int> lookup ×6 | 144 B |
status.ToString() ×6 | 144 B |
Enum.GetName(status) ×6 | 0 B |
(int)status ×6 | 0 B |
object o = (int?)42 | 24 B |
object o = (int?)null | 0 B |
แถวแรกได้ 0 B เพราะ runtime เลือก EnumEqualityComparer<T> ให้เองเมื่อไม่ระบุ comparer (ตรวจชนิดจริงด้วย EqualityComparer<T>.Default.GetType().Name) คนที่ “ช่วย” ด้วยการเขียน comparer เองแบบตรงไปตรงมาจึงทำให้แย่ลง ไม่ใช่ดีขึ้น
288 B ของหกครั้งคือ 48 B ต่อการค้นหนึ่งครั้ง ซึ่งเท่ากับ box สองก้อนตามที่คอมเมนต์ใน code บอกไว้ ส่วน Dictionary<Enum, int> ที่ประกาศ key เป็น Enum ตรงๆ เสีย 144 B คือ 24 B ต่อครั้ง
ฝั่ง struct ที่เป็น key กติกาเดียวกันแต่ผลรุนแรงกว่ามาก
public readonly struct MenuKeyPlain(int restaurantId, int menuId) // 2 int ติดกัน{ public int RestaurantId { get; } = restaurantId; public int MenuId { get; } = menuId; }
public readonly struct MenuKeyWithName(int restaurantId, string name) // มี field อ้างอิง{ public int RestaurantId { get; } = restaurantId; public string Name { get; } = name; }
public readonly struct MenuKeyPadded(byte tier, int menuId) // มี padding{ public byte Tier { get; } = tier; public int MenuId { get; } = menuId; }
public readonly struct MenuKeyFast(int restaurantId, int menuId) : IEquatable<MenuKeyFast>{ public int RestaurantId { get; } = restaurantId; public int MenuId { get; } = menuId; public bool Equals(MenuKeyFast o) => RestaurantId == o.RestaurantId && MenuId == o.MenuId; public override bool Equals(object? o) => o is MenuKeyFast k && Equals(k); public override int GetHashCode() => HashCode.Combine(RestaurantId, MenuId);}| input ที่วัด | byte |
|---|---|
HashSet<MenuKeyPlain>.Contains ×1 | 72 B |
HashSet<MenuKeyWithName>.Contains ×1 | 184 B |
HashSet<MenuKeyFast>.Contains ×1 | 0 B |
List<MenuKeyPlain>.Contains ×1 | 0 B |
List<MenuKeyWithName>.Contains ×1 | 608 B |
List<MenuKeyPadded>.Contains ×1 | 640 B |
72 B ของแถวแรกถอดได้เป็น GetHashCode 24 B บวก Equals 48 B ซึ่งวัดแยกทั้งสองชิ้นแล้วใน process เดียวกัน
ราคาต่อการเทียบหนึ่งครั้งผ่าน EqualityComparer<T>.Default วัดแยกได้ตามนี้
| input ที่วัด | byte ต่อการเทียบหนึ่งครั้ง | comparer ที่ runtime เลือก |
|---|---|---|
MenuKeyPlain — 2 int, Unsafe.SizeOf = 8 | 48 B | ObjectEqualityComparer<T> |
MenuKeyWithName — int + string, Unsafe.SizeOf = 16 | 152 B | ObjectEqualityComparer<T> |
MenuKeyPadded — byte + int, Unsafe.SizeOf = 8 | 184 B | ObjectEqualityComparer<T> |
MenuKeyFast — 2 int + IEquatable<T> | 0 B | GenericEqualityComparer<T> |
IEquatableIEquatableinterface ที่ทำให้ collection เทียบ struct ได้โดยไม่ตกไปที่ `ValueType.Equals` ซึ่งใช้ reflection และ box ทุกครั้งที่เทียบ คือเส้นแบ่งระหว่าง2 column ขวาทั้งตาราง ไม่ใช่ขนาดของ struct ไม่ใช่จำนวน field และไม่ใช่ว่า struct นั้น readonly หรือไม่
กฎที่ท่องกันว่า “struct ที่ไม่ implement IEquatable<T> จะ box ทุกครั้งที่เทียบ” ถูกครึ่งเดียว วัดจริงได้แบบนี้
List<MenuKeyPlain>.Containsเรียกครั้งเดียว = 0 BList<MenuKeyWithName>.Containsเรียกครั้งเดียว = 608 B (ต่างกันแค่เปลี่ยน field ที่สองจากintเป็นstring)List<MenuKeyPadded>.Containsเรียกครั้งเดียว = 640 B (ต่างกันแค่เปลี่ยน field แรกจากintเป็นbyteซึ่งทำให้เกิด padding)
ราคาต่อการเทียบหนึ่งครั้งของสามตัวนี้คือ 48 B, 152 B และ 184 B ตามลำดับ
0 B ของแถวแรกมาจากการที่ runtime ตัดสินได้ว่า struct ตัวนั้นเทียบแบบ bitwise ได้ ซึ่งเป็น implementation detail ห้ามสอนเป็นกฎว่า List<T>.Contains บน struct ไม่เสียอะไร การเติม field เดียวก็พลิกได้แล้ว และไม่มี warning ใดเตือน
ทางแก้ที่วัดแล้วได้ 0 B ทุกแถวคือ implement IEquatable<T> พร้อม override Equals(object?) และ GetHashCode() ให้ครบ
string.Format เทียบ interpolation คำเตือนเก่าที่เล็งผิดเป้า
หัวข้อที่มีชื่อว่า “string.Format เทียบ interpolation คำเตือนเก่าที่เล็งผิดเป้า”ส่วนเกินของ
string.Formatมีจริง แต่มันไม่ใช่object[]อย่างที่เชื่อกันมา และ interpolation ก็ไม่ได้ฟรีทุกช่อง
var inv = System.Globalization.CultureInfo.InvariantCulture;int id = 1042, qty = 2, satang = 18000;
// ทั้งสองบรรทัดให้ string เดียวกัน "order 1042 x2 = 18000" (21 อักขระ)string a = string.Format(inv, "order {0} x{1} = {2}", id, qty, satang); // 136 Bstring b = $"order {id} x{qty} = {satang}"; // 64 B
// string ยาว 21 อักขระ ตัวเปล่าๆ วัดได้ 64 B -> b ไม่มีส่วนเกินเลยแม้แต่ byte เดียวstring baseline = new string('x', 21); // 64 B
// ทางแก้เมื่อ format string ต้องเป็นข้อมูล เปลี่ยนตอน runtime ไม่ได้เขียนตรงๆstatic readonly System.Text.CompositeFormat Cf = System.Text.CompositeFormat.Parse("order {0} x{1} = {2}");string c = string.Format(inv, Cf, id, qty, satang); // 64 B| input ที่วัด | byte | string ผลลัพธ์เปล่าๆ ที่วัดแยก |
|---|---|---|
string.Format(inv, "order {0} x{1} = {2}", 1042, 2, 18000) | 136 B | 64 B (21 อักขระ) |
$"order {id} x{qty} = {satang}" input เดียวกัน | 64 B | 64 B (21 อักขระ) |
string.Format ที่ 4 argument | 176 B | 80 B (28 อักขระ) |
$"..." ที่ 4 argument | 80 B | 80 B (28 อักขระ) |
string.Format ที่ 6 argument | 216 B | 72 B |
string.Format(inv, Cf, 1042, 2, 18000) — CompositeFormat | 64 B | 64 B (21 อักขระ) |
ส่ง object[] ที่เตรียมไว้แล้วเข้าไป | 80 B | — |
FormattableString fs = $"..." | 152 B | — |
StringBuilder.Append($"order {id} x{qty} = {satang}") | tier-1: 0 B · tier-0: 72 B | — |
dest.TryWrite($"...") ลง buffer stackalloc | 0 B | — |
แถว StringBuilder.Append เป็นแถวเดียวของตารางนี้ที่การวัดจับได้ว่าตัวเลขต่างกันระหว่าง tier จึงต้องลงทั้งสองค่าตามกฎของบทนี้ ที่ tier-1 ได้ 0 B ส่วนตอนที่ code ยังเป็น tier-0 อ่านได้ 72 B ซึ่งคือ box สามก้อนตามจำนวนช่อง คู่ตัวเลขนี้ไปอยู่ในตารางรวมของหัวข้อ tier-0 เทียบ tier-1 ข้างล่างด้วย
เอาส่วนเกินมาวางเทียบกับตารางขนาดของ box ในหัวข้อแรก แล้วมันตรงกันพอดีทุกกรณี
- ที่ 3 argument 136 B ลบ string ผลลัพธ์ 64 B เหลือ 72 B = box ของ
int24 B สามก้อนพอดี - ที่ 4 argument 176 B ลบ string ผลลัพธ์ 80 B เหลือ 96 B = สี่ก้อนพอดี ขณะที่
new object[4]วัดได้ 56 B ซึ่งไม่ปรากฏในตัวเลขเลย - ที่ 6 argument 216 B = string 72 B บวก 144 B = หกก้อนพอดี ขณะที่
new object[6]วัดได้ 72 B ซึ่งก็ไม่ปรากฏอีก
เหตุผลคือ .NET 10 มี overload string.Format(IFormatProvider, string, ReadOnlySpan<object>) (ยืนยันด้วย reflection บน typeof(string).GetMethods()) compiler จึงไม่ต้องสร้าง array ให้ก่อน
ทางแก้จึงคือกำจัด box ไม่ใช่กำจัด array และทางแก้ที่วัดแล้วได้ 64 B เท่ากับ interpolation เป๊ะคือ CompositeFormat ซึ่งใช้ได้แม้ format string จะเป็นข้อมูลที่เปลี่ยนตอน runtime
ฝั่ง interpolation ก็ไม่ได้ฟรีทุกช่อง interpolated string handlerinterpolated string handlerโครงที่ compiler แปลง `$"…"` ไปเรียกแทนการต่อ string ทำให้ช่องที่เป็น int ไม่เสียส่วนเกิน แต่ยัง box struct ที่ไม่ implement `ISpanFormattable` รู้จักชนิดพื้นฐานและชนิดที่ implement ISpanFormattable แต่ชนิดที่มันไม่รู้จักยังต้อง box ราคาต่อช่องวัดได้ตามนี้ โดยหักค่าฐานของ string ผลลัพธ์ที่วัดแยกแล้วออกทุกแถว
input ที่วัด — ชนิดของช่องใน $"..." | ส่วนเกินต่อช่อง |
|---|---|
int | +0 B |
| enum | +0 B |
struct ที่ implement ISpanFormattable | +0 B |
struct ที่ implement IFormattable อย่างเดียว | +32 B |
struct ที่ override ToString() แต่ไม่ implement อะไรเลย | +32 B |
struct ที่ ไม่ override ToString() | +24 B |
สองแถว +32 B ไม่ใช่ box แต่เป็น string กลางทางที่เกิดขึ้นก่อนจะถูกคัดลอกลงผลลัพธ์ ส่วนแถวสุดท้าย +24 B คือ box หนึ่งก้อนเป๊ะ ตัวอย่างจริงคือ $"total {plainNoTs}" ที่ช่องเป็น struct ซึ่งไม่ override ToString() วัดได้ 112 B ขณะที่ string ผลลัพธ์ 31 อักขระวัดได้ 88 B
tier-0 เทียบ tier-1 code ชุดเดียวกัน ตัวเลขคนละค่า
หัวข้อที่มีชื่อว่า “tier-0 เทียบ tier-1 code ชุดเดียวกัน ตัวเลขคนละค่า”จุดที่การวัด boxing หลอกคนวัดได้แนบเนียนที่สุด และเหตุผลที่บทนี้ต้องลงเลขสองค่าเสมอ
tiered compilationtiered compilationกลไกที่ JIT compile method แบบเร็ว-หยาบก่อน (tier-0) แล้วค่อย compile ใหม่แบบเต็ม (tier-1) เมื่อถูกเรียกบ่อยพอและตัวจับเวลาเดินครบ ทำให้ code ชุดเดียวกันรายงาน allocation คนละค่าได้ ทำให้ JIT compile method แบบเร็วและหยาบก่อน แล้วค่อย compile ใหม่แบบเต็มเมื่อถูกเรียกบ่อยพอ และ optimizing JIT เท่านั้นที่ลบ box บางก้อนทิ้งได้ วิธีจับสองสถานะจึงต้องเขียนเครื่องวัดสองตัว
// เรียกแค่ 3 ครั้ง: code ยังอยู่ tier-0 (quick JIT ไม่ optimize)static long ColdDelta(Action body){ body(); body(); long before = GC.GetAllocatedBytesForCurrentThread(); body(); return GC.GetAllocatedBytesForCurrentThread() - before;}
// เรียกซ้ำสลับกับ Thread.Sleep เพื่อเปิดช่องให้ background JIT compile tier-1 เสร็จstatic void Settle(Action body){ for (int i = 0; i < 200; i++) body(); Thread.Sleep(150); for (int i = 0; i < 200; i++) body(); Thread.Sleep(150); for (int i = 0; i < 100; i++) body();}| input ที่วัด | tier-0 | tier-1 |
|---|---|---|
flags.HasFlag(Rush) ×6 | 288 B | 0 B |
StringBuilder.Append($"order {id} x{qty} = {satang}") | 72 B | 0 B |
$"{satSpan}" | 72 B | 48 B |
$"{sat}" | 104 B | 80 B |
$"{sat:N0}" | 112 B | 88 B |
$"{satSpan:N0}" | 72 B | 48 B |
object o = x; o.ToString() | 56 B | 32 B |
int[8] ที่ไม่หลุดออกจาก method | 56 B | 0 B |
TotalViaInterface ×6 บรรทัด (ฉากหลักของบทนี้) | 144 B | 144 B |
สี่แถวที่เป็น $"..." ช่องเดียว มีส่วนต่างระหว่าง2 tier เท่ากับ 24 B พอดี ซึ่งคือ box หนึ่งก้อนตามตารางในหัวข้อแรก ส่วนแถว StringBuilder.Append ต่างกัน 72 B เพราะ string นั้นมีสามช่อง จึงเป็น box สามก้อน
flowchart TD
A[ซอร์สบรรทัดเดียวกัน เรียก flags.HasFlag Rush หกครั้ง] --> B{JIT compile ถึงขั้นไหนแล้ว}
B -- ยังเป็น tier-0 --> C[อ่านเดลตาได้ 288 byte]
B -- เลื่อนขั้นเป็น tier-1 แล้ว --> D[อ่านเดลตาได้ 0 byte]
C --> E[process อายุสั้น cold path และ serverless จ่ายเลขนี้จริง]
D --> F[app ที่รันยาวจ่ายเลขนี้]
G[ซอร์สอีกบรรทัด parameter เป็น interface หกบรรทัดออร์เดอร์] --> H[อ่านเดลตาได้ 144 byte]
H --> I[วัดได้ 144 byte ทุกรอบตั้งแต่รอบแรกถึงรอบสุดท้าย ไม่ขึ้นกับ tier]
คำบรรยายภาพ: call site สองแบบใต้ tiered compilation เดียวกัน · Enum.HasFlag ให้คำตอบคนละค่าระหว่าง2 tier จึงต้องอ้างสองเลขเสมอ ส่วน parameter ที่เป็น interface ให้ค่าเดียวทุก tier จึงเป็นตัวเลขที่ใช้เป็นข้อสรุปของบทได้ตรงๆ
code เดียวกัน เครื่องเดียวกัน ไบนารีเดียวกัน ให้สองคำตอบ input คือการเรียก flags.HasFlag(Rush) หกครั้งติดกัน
- tier-0: 288 B คือ 48 B ต่อการเรียกหนึ่งครั้ง ซึ่งเท่ากับ box สองก้อน ตรงกับที่คำเตือนเก่าบอกไว้เป๊ะ
- tier-1: 0 B เท่ากับ
(flags & Rush) != 0ที่วัดได้ 0 B ในทุกกรณี
ทดสอบการลู่เข้าภายใต้ tiering ปริยาย HasFlag ได้ 288 B ในสามรอบแรก แล้วกลายเป็น 0 B ตั้งแต่รอบที่สี่เป็นต้นไปและคงที่ ซึ่งอยู่ที่ราว 60,000 การเรียกบวก Thread.Sleep ระหว่างรอบบนเครื่องนี้
ห้ามเลือกอ้างเลขเดียว เลข 288 B ไม่ใช่เกร็ดทางทฤษฎี process อายุสั้น cold path และ serverless จ่ายเลขนั้นจริง ส่วนเลข 0 B ก็ไม่ใช่การโกง มันคือเลขที่ app ซึ่งรันยาวจ่ายจริงเช่นกัน คำตอบที่ซื่อสัตย์ต่อข้อมูลคือ “ขึ้นกับว่า code นั้นถูกเลื่อนขั้นแล้วหรือยัง”
ขีดจำกัดที่ต้องเขียนตรงๆ
หัวข้อที่มีชื่อว่า “ขีดจำกัดที่ต้องเขียนตรงๆ”สิ่งที่ข้อมูลชุดนี้ยังพิสูจน์ไม่ได้ และจะไม่ถูกอ้างในบทถัดๆ ไป
- ห้ามอ่านตัวเลขทั้งบทเป็นตัวเลขของ ”.NET ทั่วไป” ทุกแถวผูกกับ linux-x64, SDK 10.0.302 / runtime 10.0.10, i7-7700 8 cores บน WSL2, workstation non-concurrent GC, TieredPGO ปิด,
-c Release· ขนาด box ที่หัว 16 byte เป็นของ x64 บน 32-bit หรือ NativeAOT ตัวเลขชุดนี้เปลี่ยน และบทนี้ไม่ได้วัดสองอย่างนั้น - ห้ามอ้างว่า “ตัวเลขนี้ดีขึ้นจาก .NET 8” บทนี้ไม่ได้วัด .NET 9 หรือเก่ากว่าเลยแม้แต่รันเดียว พูดได้แค่ “บน .NET 10.0.10 วัดได้เท่านี้”
- ตัวเลขเวลาคู่เดียวของบทนี้ใช้เป็นข้ออ้างหลักไม่ได้ BDN รันเดียวบน WSL2 ที่เตือน multimodal และ StdDev ราวหนึ่งในสี่ของ mean ข้อสรุปทั้งบทอ่านจาก column byte
- ห้ามสอนว่า “
List<T>.Containsบน struct ไม่เสียอะไร” เป็นกฎ 0 B ขึ้นกับการที่ runtime ตัดสินว่า struct นั้นเทียบแบบ bitwise ได้ ซึ่งเป็น implementation detail ที่การเติม field เดียวก็พลิกได้ - เลขทางการของบทนี้วัดใต้
DOTNET_TieredCompilation=0ห้ามนำเสนอเป็น “สิ่งที่ app ของคุณเจอเสมอ” tiering ปริยายลู่เข้าเลขเดียวกันได้ แต่ใช้ราว 60,000 การเรียก และห้ามใส่สวิตช์นั้นลง project ที่ผู้อ่านจะลอกไปวัดต่อ - ตัวเลข LINQ ทั้งชุดมาจากลำดับ 6 สมาชิก และเป็นต้นทุนตั้งต้นแบบคงที่ ห้าม extrapolate ขึ้นไปเป็นลำดับที่ใหญ่กว่า
- การถอดรหัสตัวเลขเป็น “กี่ box บวก string เท่าไร” ในบทนี้ ยืนบนชิ้นส่วนที่วัดแยกจริงทุกชิ้น ถ้าอยากได้การถอดรหัสที่ไม่อยู่ในตารางเหล่านี้ ต้องกลับไปวัดใหม่ ห้ามคำนวณจากสูตร
- ยังไม่ได้วัด NativeAOT, ARM64, server GC, source generator ของ logging, เส้นทาง async และ
Dictionaryที่มี hash collision จริง
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”สามข้อที่ตายแล้ว สามที่ที่ยังเสียจริง และกฎเดียวที่ใช้ตัดสินทั้งหมด
คำเตือนเก่าที่บทนี้วัดแล้วพบว่าเล็งผิดเป้าบน .NET 10.0.10
string.Formatไม่จองobject[]อีกแล้ว ส่วนเกินคือ box ล้วนๆ (72 B ที่ 3 argument, 96 B ที่ 4, 144 B ที่ 6) ทางแก้คือCompositeFormatที่วัดได้ 64 B เท่ากับ interpolation เป๊ะ$"..."ที่ช่องเป็นintไม่มีส่วนเกินเลย 64 B ของมันคือ string ผลลัพธ์ 21 อักขระพอดีEnum.HasFlag= 0 B เมื่อ code ถูกเลื่อนขั้นแล้ว และ = 288 B ต่อหกการเรียกเมื่อยังเป็น tier-0 ทั้งสองเลขจริงทั้งคู่
boxing ตัวจริงที่ยังเหลืออยู่ และวัดได้ในบทนี้
- parameter ที่ประกาศเป็น interface — ออร์เดอร์ 6 บรรทัดเสีย 144 B ทุก tier ส่วน generic constraint เสีย 0 B โดยเนื้อ method เหมือนกันทุกตัวอักษร
- enumerator ที่ลอดผ่าน
IEnumerable<T>— 40 B ต่อการไล่หนึ่งครั้ง ไม่ว่าจะเขียนเป็นforeachหรือlist.Sum(...) - struct ที่ลืม
IEquatable<T>—List<T>.Containsครั้งเดียวขยับจาก 0 B เป็น 608 B ด้วยการเติม fieldstringตัวเดียว และเป็น 640 B เมื่อมี padding
กฎที่ใช้ตัดสินทั้งบทมีข้อเดียว อย่าเชื่อคำเตือนที่ไม่มีตัวเลขกำกับ และอย่าเชื่อตัวเลขที่ไม่บอกว่าวัดที่ tier ไหน
บท 4 เปลี่ยนคำถามจาก “ค่าถูกห่อไหม” เป็น “ต้องคัดลอกข้อมูลไหม” เพราะเมื่อ box หมดไปแล้ว ผู้ร้ายรายใหญ่ที่เหลือใน hot path ของ parse บรรทัดออร์เดอร์คือ string ใหม่ที่ string.Split คืนมาทุก field และเครื่องมือที่จะลบมันคือ Span<char> กับ stackalloc ซึ่งมาพร้อมข้อจำกัดที่ compiler บังคับ ไม่ใช่คำแนะนำ
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Boxing and Unboxing (C# Programming Guide) — Microsoft Learn (2025-10-17 · อ่านจริง 2026-08-07) — นิยามของ boxing และ unboxing ที่บทนี้ใช้ตลอด
- Performance Improvements in .NET 10 — .NET Blog (2025-09-10 · ยืนยัน 2026-08-07) — object stack allocation กับ devirtualization ซึ่งเป็นกลไกเบื้องหลังคู่ตัวเลข tier-0 กับ tier-1 ทั้งตาราง
- DefaultInterpolatedStringHandler Struct — Microsoft Learn (net-10.0) (2026-05-27) — โครงที่ compiler แปลง
$"..."ไปเรียก และเหตุผลที่ช่องบางชนิดไม่เสียส่วนเกิน - CompositeFormat Class (System.Text) — Microsoft Learn (net-8.0+) (2026-05-27) — ทางแก้ที่วัดได้ 64 B เมื่อ format string ต้องเป็นข้อมูล
- GC.GetAllocatedBytesForCurrentThread Method — Microsoft Learn (2026-07-01) — ตัวนับที่ทุกเดลตาในบทนี้อ่านมา
- Compilation config settings for .NET — Microsoft Learn (2025-12-03) —
DOTNET_TieredCompilationที่ใช้แยกเลข tier-0 ออกจากเลข tier-1 - Tiered Compilation — dotnet/runtime design docs (ยืนยัน 2026-08-07) — กลไกที่ทำให้ซอร์สบรรทัดเดียวกันรายงาน allocation คนละค่า
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3method สองตัวมีเนื้อในเหมือนกันทุกตัวอักษร ต่างกันแค่ parameter ตัวหนึ่งประกาศเป็น IPricedLine อีกตัวประกาศเป็น T พร้อม where T : IPricedLine เมื่อไล่ออร์เดอร์ 6 บรรทัดชุดเดียวกัน วัดได้ 144 B กับ 0 B ข้อใดอธิบายตัวเลข 144 B ได้ตรงกับที่วัดจริง