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

struct กับ class: เจ้าของ​ตัดสิน ไม่ใช่ keyword

คำถาม​แรก​ที่​ทุก​คน​ถาม​เมื่อ​เห็น​คำ​ว่า performance คือ เปลี่ยน class เป็น struct แล้ว​จะ​เร็ว​ขึ้น​ไหม

บท​ที่ 1 เพิ่ง​ประกาศ​เส้น​ขอบเขต​ไป​ว่า​คอร์ส​นี้​ตัดสิน​ทุก​ข้อ​สรุป​ด้วย column Allocated ไม่ใช่ column Mean แล้ว​บท​นี้​ก็​เป็น​บท​แรก​ที่​ต้อง​ยอมรับ​ว่า​เครื่องมือ​นั้น ตอบ​คำถาม​ของ​บท​นี้​ไม่​ได้ loop 10,000 รอบ​สี่​แบบ​ที่​กิน​ต้นทุน​ต่าง​กัน​คนละ​เรื่อง วัด​ออก​มา​ได้ 0 byte เท่า​กัน​หมด ไม่ใช่​เพราะ​ทั้ง​สี่​แบบ​เท่า​กัน แต่​เพราะ​สิ่ง​ที่​ต่าง​กัน​ไม่ใช่​การ​จอง​บน heap

บท​นี้​จึง​ไม่​กลบ​ข้อ​จำกัด​นั้น​ด้วย graph สวยๆ มัน​ประกาศ​ออก​มาตรงๆ แล้ว​เปลี่ยน​ไป​ใช้​หลักฐาน​คนละ​ชนิด​แทน คือ ภาษา​เครื่อง​ที่ JIT ปล่อย​ออก​มา​จริง ซึ่ง​นับ​คำ​สั่ง copy ได้ที​ละ​คำ​สั่ง และ​ซ้ำ​ได้​ทุก​ครั้ง​เหมือน​กับ column byte

📦 kaen-food-ordering

บท​นี้​ยัง​เดิน​บน hot path เส้น​เดิม​ของ domain Order ที่​คอร์ส #8–#11 สร้าง​ไว้ คือ parse บรรทัด​ออร์เดอร์ → รวม​ยอด → พิมพ์​ใบเสร็จ (repo ตัวอย่าง .NET กำลัง​จัด​ทำ) ชนิด​ข้อมูล​ที่​ใช้​ตลอด​บท​คือ​บรรทัด​ออร์เดอร์​หนึ่ง​บรรทัด (Id, Name, Qty, PriceSatang) กับ snapshot ของออร์เดอร์​หนึ่ง​ใบ ทั้ง​สอง​ตัว​มี​ทั้ง version struct และ version class เพื่อ​ให้​เทียบ​กันได้ตรงๆ

ทุก​ตัวเลข​ใน​บท​นี้​วัด​บน​เครื่อง 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 · x64 (IntPtr = 8) และ​ทุก block มี block ID กำกับ​ตาม​ที่​บท​ที่ 1 ตั้ง precedent ไว้

ไวยากรณ์ C# 11–14 ที่​ใช้​ใน​บท​นี้ (primary constructor บน struct และ class, collection expression = [...]) คอร์ส​นี้ ใช้ โดย​ไม่​หยุด​อธิบาย ถ้า​เจอ​ของ​ที่​ไม่​คุ้น​ให้​เปิด C# 8 → 14: อะไร​เปลี่ยน​ไป​บ้าง ควบคู่​ไป

”struct อยู่​บน stack class อยู่​บน heap” เป็น​แบบ​จำลอง​ที่​ผิด

หัวข้อ​ที่​มีชื่อ​ว่า “”struct อยู่​บน stack class อยู่​บน heap” เป็น​แบบ​จำลอง​ที่​ผิด”

ขนาด​จริง​ของ​ชนิด​ข้อมูล​ที่​ทั้ง​บท​จะ​ใช้ แล้ว​ตาม​ด้วย​สาม​ทาง​ที่ struct ตัว​เดียวกัน​เดิน​ไป​โผล่​บน heap ได้​โดย​ไม่​แก้​ซอร์ส​แม้แต่​ตัว​อักษร​เดียว

เริ่ม​จาก​ของ​จริง​ก่อน บรรทัด​ออร์เดอร์​หนึ่ง​บรรทัด​ใน2 version ที่​มี field เหมือนกันเป๊ะ

using System.Runtime.CompilerServices;
public sealed class OrderLineClass(int id, string name, int qty, int priceSatang)
{
public int Id = id; public string Name = name;
public int Qty = qty; public int PriceSatang = priceSatang;
}
public readonly struct OrderLineStruct(int id, string name, int qty, int priceSatang)
{
public readonly int Id = id; public readonly string Name = name;
public readonly int Qty = qty; public readonly int PriceSatang = priceSatang;
}
// _sink เป็น static เพื่อกันไม่ให้ escape analysis ของ .NET 10 พิสูจน์ว่า object ไม่หลุด
// แล้วย้ายมันไปวางบน stack ซึ่งจะทำให้ตัวเลขที่เราต้องการวัดหายไปเงียบๆ โดยไม่มี warning
static object? _sink;
static long B() => GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine(Unsafe.SizeOf<OrderLineStruct>());
for (int i = 0; i < 200; i++) _sink = new OrderLineClass(1042, "ต้มยำกุ้ง", 2, 18000);
long a = B();
_sink = new OrderLineClass(1042, "ต้มยำกุ้ง", 2, 18000);
Console.WriteLine(B() - a);
input ที่​วัดสิ่ง​ที่​อ่าน​ได้
Unsafe.SizeOf<OrderLineStruct>()24 B
new OrderLineClass(1042, "ต้มยำกุ้ง", 2, 18000)40 B
Unsafe.SizeOf<OrderSnapshot>()112 B
new OrderSnapshotClass()128 B
Unsafe.SizeOf<MutableTally>()16 B

ส่วน​ต่าง​ระหว่าง​คู่ struct กับ class ใน​ตาราง​นี้​คือ​ค่าหัว​ของ object บน heap ซึ่ง​เป็น layout ของ x64 (IntPtr = 8) ไม่ใช่​สัญญา​ที่ runtime รับประกัน ถ้า​ย้าย​ไป​สถาปัตยกรรม​อื่น ตัวเลข​ชุด​นี้​เปลี่ยน​ทั้ง​ชุด

คำถาม​ที่​สำคัญ​กว่า​คือ struct ตัว​นั้น อยู่​ที่ไหน และ​คำ​ตอบ​ไม่​ได้​อยู่​ที่ keyword แต่​อยู่​ที่​ว่า​ใคร​ถือ​มัน​ไว้

public sealed class HolderOfStruct { public OrderLineStruct Line; }
public sealed class HolderOfClass { public OrderLineClass? Line; }
static object MakeHolderOfStruct()
{
var h = new HolderOfStruct();
h.Line = new OrderLineStruct(1042, "ต้มยำกุ้ง", 2, 18000);
return h; // struct เดินทางไป heap พร้อมเจ้าของ
}
static object CaptureStructInClosure()
{
var line = new OrderLineStruct(1042, "ต้มยำกุ้ง", 2, 18000);
Func<int> f = () => line.Qty * line.PriceSatang; // ถูกจับ -> heap
return f;
}
static object BoxOneLine()
{
var line = new OrderLineStruct(1042, "ต้มยำกุ้ง", 2, 18000);
return (object)line;
}
input ที่​วัดจำนวน byteรูปร่าง​ที่​ได้
class { OrderLineStruct Line; }40 Bก้อน​เดียว struct ฝัง​อยู่​ใน​เจ้าของ
class { OrderLineClass Line; }64 Bสอง​ก้อน ตัว​ถือ​กับ​ตัว​ที่​ถูก​ชี้
local struct ที่ Func<int> จับ​ไว้104 Bclass ปิด​ล้อม​ที่ compiler สร้าง​ให้
(object)line40 Bกล่อง​ที่​ห่อ struct ไว้​บน heap
Func<int> ที่​ชี้ static method0 Bไม่มี​อะไร​ถูก​จับ จึง​ไม่มี​ก้อน​ใหม่
new OrderLineStruct[8]216 Barray ก้อน​เดียว ค่า​อยู่​ใน​ตัว array
new OrderLineClass[8]88 Barray ของ reference ล้วน ยัง​ไม่มี​ของ
new OrderLineClass[8] แล้ว​เติม​ของ​จริง​ครบ 8 ตัว408 Barray บวก object แปด​ก้อน
new OrderLineStruct[1000]24024 Bก้อน​เดียว
new OrderLineClass[1000] เติม​เต็ม48024 Barray บวก object พัน​ก้อน

แถว​ที่​ควร​อ่าน​ช้าๆ คือ​แถว 88 B กับ 408 B ซึ่ง​เป็น input เดียวกัน​แต่​คนละ​สถานะ array ของ class ที่​ยัง​ไม่​เติม​ของ​อ่าน​ได้ 88 B ซึ่ง ดูดี กว่า array ของ struct ที่ 216 B ทันที แต่​มัน​ดู​ดี​เพราะ​ยัง​ไม่มี​ของ​อยู่​ใน​นั้น พอ​เติม​ครบ​แปด​ตัว​ตัวเลข​ก็​ขึ้น​ไป​ที่ 408 B

flowchart TD
    A["OrderLineStruct หนึ่งตัว ซอร์สไม่เปลี่ยนสักตัวอักษร"] --> B{"ใครเป็นเจ้าของมัน"}
    B -- "ตัวแปรโลคัลที่ไม่มีใครจับ" --> C["อยู่ใน frame ของ method ไม่มีก้อนใหม่บน heap"]
    B -- "เป็น field ของ class" --> D["เดินทางไป heap พร้อมเจ้าของ อ่านได้ 40 B ก้อนเดียว"]
    B -- "ถูก lambda จับไว้" --> E["ไปอยู่ใน class ปิดล้อม อ่านได้ 104 B"]
    B -- "ถูกแปลงเป็น object" --> F["ไป heap ในกล่องของตัวเอง อ่านได้ 40 B"]
    C --> G["keyword struct ไม่ได้ตัดสินตำแหน่ง เจ้าของตัดสิน"]
    D --> G
    E --> G
    F --> G

คำ​บรรยาย​ภาพ: เส้นทาง​ของ struct ตัว​เดียวกัน​ภาย​ใต้​เจ้าของ​สี่​แบบ ตัวเลข​ทุก​ตัว​มา​จาก​การ​วัด​ชุด M2.2 บน​เครื่อง i7-7700 เครื่อง​เดียวกัน · สาม​ใน​สี่​เส้นทาง​พา struct ขึ้น​ไป​อยู่​บน heap โดยที่​ซอร์ส​ของ​ตัว struct เอง​ไม่​ได้​เปลี่ยน​แม้แต่​ตัว​อักษร​เดียว · แบบ​จำลอง stack กับ heap จึง​พยากรณ์​อะไร​ไม่​ได้​เลย ถ้า​ไม่​ดู​ก่อน​ว่า​ใคร​ถือ​มัน​ไว้

ทาง​กลับ​กัน​ก็​จริง​เหมือน​กัน class ที่​ไม่​หลุด​ออก​นอก method ก็​อาจ​ไม่​แตะ heap

หัวข้อ​ที่​มีชื่อ​ว่า “ทาง​กลับ​กัน​ก็​จริง​เหมือน​กัน class ที่​ไม่​หลุด​ออก​นอก method ก็​อาจ​ไม่​แตะ heap”

method ที่​สร้าง object class แล้ว​ใช้​มัน​จบ​ใน​ตัวเอง ซึ่ง​อ่าน​ได้ 32 byte ที่ tier-0 และ 0 byte ที่ tier-1 จาก​ซอร์ส​บรรทัด​เดียวกัน

sealed class OrderLineNoRefs(int id, int menuId, int qty, int price)
{ public int Id = id; public int MenuId = menuId; public int Qty = qty; public int Price = price; }
static long LongSink;
[MethodImpl(MethodImplOptions.NoInlining)]
static void NonEscaping()
{
var line = new OrderLineNoRefs(1042, 7, 2, 18000);
LongSink = line.Qty * (long)line.Price; // ไม่หลุดออกจาก method
}
[MethodImpl(MethodImplOptions.NoInlining)]
static long Probe()
{
long a = GC.GetAllocatedBytesForCurrentThread();
NonEscaping();
return GC.GetAllocatedBytesForCurrentThread() - a;
}
for (int round = 0; round < 8; round++)
{
for (int i = 0; i < 2000; i++) NonEscaping();
Console.WriteLine($"round {round}: {Probe()} B");
Thread.Sleep(200);
}
input ที่​วัดJIT configสิ่ง​ที่​อ่าน​ได้
new OrderLineNoRefs(1042, 7, 2, 18000) ที่​ไม่​หลุด, round 0tiering ปกติ (tier-0)32 B
input เดียวกัน, round 1–7 หลัง Thread.Sleep(200) ทุกรอบtiering ปกติ (tier-1 แล้ว)0 B
input เดียวกัน, การ​เรียก​ครั้ง​แรก​สุดDOTNET_TieredCompilation=00 B
input เดียวกัน, round 0DOTNET_TC_CallCountingDelayMs=00 B

นี่​คือ​ฝั่ง​กลับ​ของ​หัวข้อ​ที่​แล้ว object ที่​ประกาศ​ด้วย class ก็​ไม่​แตะ heap ได้​เหมือน​กัน ถ้า JIT พิสูจน์​ได้​ว่า​มัน​ไม่​หลุด​ออก​นอก method — แต่​สังเกต​ว่า​คำ​ตอบ​มา​เป็น​ตัวเลข​สอง​ตัว​คู่​กัน ไม่ใช่​ตัวเลข​เดียว process ที่​อายุ​สั้น​หรือ cold path จ่าย​เลข tier-0 คือ 32 B จริงๆ

และ​กับดัก​ของ​บท​ที่ 1 ก็​ยิง​ซ้ำ​ตรง​นี้​พอดี ถ้า​เปลี่ยน​ไป​อุ่นเครื่อง​ด้วย จำนวน​รอบ ใน loop เดียว​รวด แทน​โครง​แปด​รอบ​ข้าง​บน​ที่​ปล่อย​ให้​เวลา​เดิน ตัวเลข​จะ​ค้าง​อยู่​ที่ 32 B ตลอด วัด​ที่ 0, 10, 30, 100, 200, 1,000, 5,000, 20,000, 100,000 และ 500,000 ครั้ง ได้ 32 B ทุก​จุด แม้​จะ​เติม Thread.Sleep(500) เข้าไป​แล้ว​ก็ตาม ขณะ​ที่​โครง​แปด​รอบ​ข้าง​บน​อ่าน​ได้ 0 B ตั้งแต่ round 1 เป็นต้น​ไป จำนวน​รอบ​จึง​ไม่ใช่​ตัวแปร​ที่​ตัดสิน และ​สอง​แถว​ล่าง​ของ​ตาราง​ก็​ปิด​คำ​อธิบาย​อื่น​ทิ้ง คือ​ปิด tiering ทั้ง​ระบบ​ด้วย DOTNET_TieredCompilation=0 ได้ 0 B ตั้งแต่​การ​เรียก​ครั้ง​แรก​สุด และ​ปิด​การ​หน่วง​นับ​การ​เรียก​ด้วย DOTNET_TC_CallCountingDelayMs=0 ได้ 0 B ตั้งแต่ round 0 สิ่ง​ที่​กั้น​อยู่​จึง​เป็น​กลไก​เลื่อน​ขั้น​ที่​บท​ที่ 1 อธิบาย​ไว้ ไม่ใช่​จำนวน​ครั้ง​ที่​คุณ​เรียก​เอง

จุด​เดียว​ใน​คอร์ส​ที่​เครื่องมือ​หลัก​ของ​คอร์ส​ตอบ​ไม่​ได้

หัวข้อ​ที่​มีชื่อ​ว่า “จุด​เดียว​ใน​คอร์ส​ที่​เครื่องมือ​หลัก​ของ​คอร์ส​ตอบ​ไม่​ได้”

loop 10,000 รอบ​สี่​แบบ​ที่​ต้นทุน​ต่าง​กัน​คนละ​เรื่อง แต่ column byte อ่าน​ได้ 0 B เท่า​กัน​ทั้ง​สี่​แบบ

code ที่​วัด​ตัด​เหลือ loop เดียว อีก3 loop มี​รูปร่าง​เดียวกันเป๊ะ เปลี่ยน​แค่ method ที่​ถูก​เรียก​ใน​บรรทัด​กลาง

static OrderSnapshot _snap = new() { TotalSatang = 18000, DeliveryFeeSatang = 1500 };
static readonly OrderSnapshotClass _snapObj = new() { TotalSatang = 18000, DeliveryFeeSatang = 1500 };
static long LongSink;
[MethodImpl(MethodImplOptions.NoInlining)]
static long ByValue(OrderSnapshot s) => s.TotalSatang + s.DeliveryFeeSatang;
[MethodImpl(MethodImplOptions.NoInlining)]
static long ByIn(in OrderSnapshot s) => s.TotalSatang + s.DeliveryFeeSatang;
[MethodImpl(MethodImplOptions.NoInlining)]
static long ByClass(OrderSnapshotClass s) => s.TotalSatang + s.DeliveryFeeSatang;
static object? LoopByValue()
{
long t = 0;
for (int i = 0; i < 10_000; i++) t += ByValue(_snap); // copy 112 B ทุกครั้ง
LongSink = t; return null;
}
input ที่​วัดสิ่ง​ที่​เกิด​ขึ้น​จริง​ใน loopAllocated
10,000 × ByValue(_snap)คัด​ลอก OrderSnapshot 112 byte ทุก​ครั้ง รวม​เป็น​ข้อมูล​ที่​ถูก​ย้าย 1.12 MB0 B
10,000 × ByIn(in _snap)ส่ง​ที่​อยู่ ไม่​คัด​ลอก0 B
10,000 × ByClass(_snapObj)ส่ง reference ของ object ที่​มี​อยู่​แล้ว0 B
10,000 × อ่าน​ผ่าน in บน struct ที่​ไม่ใช่ readonlyเดิน​เส้นทาง defensive copy0 B

ตัวเลข 1.12 MB ใน​ตาราง​เป็น ปริมาณ​ข้อมูล​ที่​ถูก​ย้าย ไม่ใช่ byte ที่​จอง​บน heap มัน​ไม่ใช่​การ​อ้าง​เรื่อง​ประสิทธิภาพ และ​ไม่​ได้​แปล​ว่า​แถว​นั้น​ช้า​กว่า​แถว​อื่น มัน​มี​ไว้​ชี้​ให้​เห็น​ว่า​งาน​ที่​ต่าง​กัน 1.12 MB ตกลง​ไป​อยู่​ใน​ช่องว่าง​ที่ column Allocated มอง​ไม่​เห็น

บท​นี้​กำลัง​ยืม​หลักฐาน​คนละ​ชนิด​มา​ใช้ และ​ต้อง​พูด​ออก​มาตรงๆ

คอร์ส​นี้​ประกาศ​ไว้​ใน​บท​ที่ 1 ว่า “byte คือ​หลักฐาน นาโน​วินาที​ไม่ใช่” — แล้ว​บท​นี้​ก็​เป็น​บท​ที่​กติกา​ข้อ​นั้น​ไม่​พอ

ทั้ง​สี่​แถว​ข้าง​บน​อ่าน​ได้ 0 B เท่า​กัน​หมด ถ้า​เอา​ข้อมูล​ชุด​นี้​ไป​วาด​เป็น graph เปรียบเทียบ​สอง​เลน สิ่ง​ที่​ได้​คือ​เลน​สอง​เลน​ที่​แบน​เท่า​กัน​และ​ไม่​พูด​อะไร​เลย นั่น​ไม่ใช่​หลักฐาน​ว่า “ทั้ง​สี่​แบบ​เท่า​กัน” มัน​คือ​หลักฐาน​ว่า เครื่องมือ​วัด​ของ​คอร์ส​นี้​ไม่​ได้​ออกแบบ​มา​วัด​สิ่ง​ที่​บท​นี้​กำลัง​พูด​ถึง

การ​นับ allocation นับ “การ​ขอ​พื้นที่​ก้อน​ใหม่​บน heap” ส่วน​ต้นทุน​ของ struct ที่​บท​นี้​สนใจ​คือ “การ​คัด​ลอก​ข้อมูล​จาก​ที่​หนึ่ง​ไป​อีก​ที่​หนึ่ง” ซึ่ง​ไม่​ขอ​พื้นที่​ใหม่​เลย

บท​นี้​จึง​เปลี่ยน​ไป​ใช้​หลักฐาน​อีก​ชนิด คือ ขนาด​และ​หน้าตา​ของ​ภาษา​เครื่อง​ที่ JIT ปล่อย​ออก​มา ซึ่ง​อ่าน​ซ้ำ​ได้​ตรง​กัน​ทุก​ครั้ง​เหมือน column byte และ​เห็น​คำ​สั่ง copy ได้ที​ละ​คำ​สั่ง ส่วน column เวลา​นั้น​บท​นี้​วัด​แล้ว ไม่​ผ่าน​เกณฑ์​ของ​คอร์ส จึง​ถูก​ตัด​ออก​ทั้งหมด รายละเอียด​อยู่​ใน​หัวข้อ​ขีด​จำกัด​ท้าย​บท

หลักฐาน​ที่​ใช้​แทน ต้นทุน​การ copy โต​ตาม​ขนาด และ​เห็น​ได้​ใน code ที่ JIT ปล่อย​ออก​มา

หัวข้อ​ที่​มีชื่อ​ว่า “หลักฐาน​ที่​ใช้​แทน ต้นทุน​การ copy โต​ตาม​ขนาด และ​เห็น​ได้​ใน code ที่ JIT ปล่อย​ออก​มา”

การ​ไล่​ขนาด struct จาก 8 byte ถึง 256 byte แล้ว​นับ​คำ​สั่ง copy ที่ call site ที​ละ​คำ​สั่ง ซึ่ง​ให้​เส้น​แบ่ง​ที่​คม​และ​ซ้ำ​ได้​ทุก​ครั้ง

จุด​ที่​คน​ดู​ผิด​บ่อย​คือ​ดู​ใน​ตัว method ที่​รับ parameter สำเนา​ไม่​ได้​เกิด​ที่​นั่น มัน​เกิด​ที่ ฝั่ง​ผู้​เรียก method ที่​ต้อง​ส่อง​จึง​เป็น Call_* ไม่ใช่ Pass_*

code ข้าง​ล่าง​ตัด​เหลือ​สอง​ขนาด​ที่​คร่อม​เส้น​แบ่ง​พอดี ของ​จริง​มี​ครบ​ทุกขนาด​ใน​ตาราง ตั้งแต่ 8 ถึง 256 byte และ​มี​คู่​ที่​ส่ง​ด้วย ref ของ​ทุกขนาด​ด้วย

public struct S16 { public long A, B;
[MethodImpl(MethodImplOptions.NoInlining)] public long Sum() => A; }
public struct S24 { public long A, B, C;
[MethodImpl(MethodImplOptions.NoInlining)] public long Sum() => A; }
static S16 _b; static S24 _c;
public static long Sink;
[MethodImpl(MethodImplOptions.NoInlining)]
public static long Pass_016_ByValue(S16 s) => s.Sum();
[MethodImpl(MethodImplOptions.NoInlining)]
public static long Pass_024_ByValue(S24 s) => s.Sum();
// สำเนาเกิดที่ CALL SITE ไม่ใช่ในตัว callee — ต้องดู method พวกนี้
[MethodImpl(MethodImplOptions.NoInlining)]
public static void Call_016_ByValue() => Sink += Pass_016_ByValue(_b);
[MethodImpl(MethodImplOptions.NoInlining)]
public static void Call_024_ByValue() => Sink += Pass_024_ByValue(_c);
Terminal window
DOTNET_JitDisasm="Call_*" dotnet bin/Release/net10.0/SizeScan.dll
input ที่​วัด (call site)ขนาด code ที่ JIT ปล่อยจำนวน​คำ​สั่ง copy
struct 8 byte ส่ง by value50 byte0
struct 16 byte ส่ง by value63 byte0
struct 24 byte ส่ง by value68 byte2
struct 32 byte ส่ง by value77 byte4
struct 64 byte ส่ง by value99 byte8
struct 112 byte ส่ง by value135 byte14
struct 256 byte ส่ง by value80 byteเปลี่ยน​ไป​ใช้ rep movsb ที่ ecx=256
struct 8 / 112 / 256 byte ส่ง​ด้วย ref47 byte ทุกขนาด0 ทุกขนาด

สอง​แถว​แรก​คือ​หัวใจ ที่ 8 และ 16 byte JIT ไม่​ออก​คำ​สั่ง copy เลย​สัก​คำ​สั่ง ค่า​เดิน​ทางใน register rdi กับ rsi ตรงๆ คำ​สั่ง copy โผล่​ครั้ง​แรก​ที่ 24 byte แล้ว​โต​ขึ้น​เป็น​ขั้น​บันได​จนถึง 14 คำ​สั่ง​ที่ 112 byte

ที่ 256 byte ขนาด code กลับ ลดลง เหลือ 80 byte เพราะ JIT เลิก​ไล่​ปล่อย​คำ​สั่ง SIMD ที​ละ​คู่​แล้ว​เปลี่ยน​ไป​ใช้ rep movsb แทน — ซึ่ง​แปล​ว่า​ขนาด code ใช้​แทน​ต้นทุน​การ​คัด​ลอก​ได้​แค่​ใน​ช่วง​ที่ JIT ยัง​ปล่อย​คำ​สั่ง​เรียง​กัน​เท่านั้น เลย​ราว 128 byte ไป​แล้ว ตัวเลข​นี้​ไม่ใช่​ตัวแทน​ของ​อะไร​ทั้งนั้น ตัวเลข​ใน column นี้​เป็น​หลักฐาน​ว่า​คำ​สั่ง copy มี​หรือ​ไม่มี ไม่ใช่​ค่า​ประมาณ​ของ​เวลา

แถว​สุดท้าย​คือ​เหตุผล​ที่ in และ ref มี​อยู่ ส่ง​ด้วย reference แล้ว​ขนาด code คงที่​ที่ 47 byte ทุกขนาด และ​ไม่มี​คำ​สั่ง copy เลย

กฎ 16 byte ที่​ทุก​คน​อ้าง มา​จาก​เอกสาร​ปี 2008 ที่​หน้า​เว็บ​เอง​ยัง​เตือน​ว่า​อาจ​ล้าสมัย

คำ​แนะนำ “instance size under 16 bytes” ของ Framework Design Guidelines คือ​หน้าที่​มี ms.date เป็น 2008-10-22 และ​ตัว​หน้า​เว็บ​เอง​แปะ​คำ​เตือน​ไว้​ว่า​เนื้อหา​อาจ​ไม่​สะท้อน​แนว​ปฏิบัติ​ปัจจุบัน

สิ่ง​ที่​บท​นี้​ทำได้​คือ​วัด​ใหม่​เอง​บน .NET 10.0.10 / x64 แล้ว​รายงาน​ว่า​เส้น​แบ่ง​ที่ วัด​ได้​จาก codegen ตรง​กับ​ตัวเลข​นั้น​พอดี คือ 16 byte ยัง​ไม่มี​คำ​สั่ง copy และ 24 byte เริ่ม​มี

นี่​ไม่ใช่​การ​ยืนยัน​ว่า​เอกสาร​ปี 2008 ถูก มัน​คือ​การ​บอกว่า​บน​เครื่อง​นี้ runtime รุ่น​นี้ และ​สถาปัตยกรรม​นี้ ตัวเลข​สอง​ชุด​บังเอิญ​ตรง​กัน ถ้า​เปลี่ยน​สถาปัตยกรรม​หรือ​เปลี่ยน​รุ่น runtime ต้อง​วัด​ใหม่

List<struct> ไม่​ได้​แปล​ว่า​ประหยัด​เสมอ — capacity ต่างหาก​ที่​ตัดสิน

หัวข้อ​ที่​มีชื่อ​ว่า “List<struct> ไม่​ได้​แปล​ว่า​ประหยัด​เสมอ — capacity ต่างหาก​ที่​ตัดสิน”

เคส​เดียว​ของ​บท​นี้​ที่​การ​นับ byte ตอบได้ตรงๆ และ​คำ​ตอบ​ไม่ใช่​คำ​ตอบ​ที่​คน​ส่วน​ใหญ่​คาด​ไว้

หัวข้อ​ที่​แล้ว​บอกว่า​บท​นี้​ต้อง​ยืม​หลักฐาน​จาก codegen แต่​มี​คำถาม​หนึ่ง​ที่ column Allocated ตอบ​ได้​เต็ม​ปาก คือ​คำถาม​ที่​คน​เปลี่ยน List<OrderLineClass> เป็น List<OrderLineStruct> ใน codebase จริง​ถาม​กัน

static readonly string[] Names =
["ต้มยำกุ้ง", "ผัดไทย", "ข้าวมันไก่", "ส้มตำ",
"แกงเขียวหวาน", "ข้าวผัดปู", "หมูกระทะ", "ลาบหมู"];
static object BuildListClass(int n, bool withCapacity)
{
var list = withCapacity ? new List<OrderLineClass>(n) : new List<OrderLineClass>();
for (int i = 0; i < n; i++)
list.Add(new OrderLineClass(1042 + i, Names[i & 7], 2, 18000));
return list;
}
static object BuildListStruct(int n, bool withCapacity)
{
var list = withCapacity ? new List<OrderLineStruct>(n) : new List<OrderLineStruct>();
for (int i = 0; i < n; i++)
list.Add(new OrderLineStruct(1042 + i, Names[i & 7], 2, 18000));
return list;
}
input ที่​วัดList<OrderLineClass>List<OrderLineStruct>อัตราส่วน
n=8 ตั้ง capacity ไว้440 B248 B0.56x
n=100 ตั้ง capacity ไว้4856 B2456 B0.51x
n=1000 ตั้ง capacity ไว้48056 B24056 B0.50x
n=2000 ตั้ง capacity ไว้96056 B48056 B0.50x
n=8 ปล่อย​ให้​โต​เอง496 B368 B0.74x
n=100 ปล่อย​ให้​โต​เอง6192 B6224 B1.01x — struct แพ้
n=1000 ปล่อย​ให้​โต​เอง56600 B49304 B0.87x
n=1025 ปล่อย​ให้​โต​เอง74008 B98480 B1.33x — struct แพ้​หนัก​สุด

ครึ่ง​บน​ของ​ตาราง​คือ​เรื่อง​ที่​ทุก​คน​คาด​ไว้ ตั้ง capacity แล้ว List<struct> กิน 0.50x ถึง 0.56x ของ List<class> อย่าง​สม่ำเสมอ ครึ่ง​ล่าง​คือ​เรื่อง​ที่​ไม่มี​ใคร​คาด

คำถาม​ต่อ​มา​คือ n=100 กับ n=1025 เป็น​ข้อ​ยกเว้น​หรือ​เป็น​เรื่อง​ปกติ วัด​หมด​ทุก​ค่า​เลย​ดี​กว่า

int worse = 0, better = 0; long worstDiff = 0; int worstN = 0;
for (int n = 1; n <= 2000; n++)
{
long c = MeasureOnce(() => BuildListClass(n, false));
long s = MeasureOnce(() => BuildListStruct(n, false));
if (s >= c) { worse++; if (s - c > worstDiff) { worstDiff = s - c; worstN = n; } }
else better++;
}
Console.WriteLine($"{worse}/2000 worse, {better}/2000 better, worst +{worstDiff} B at n={worstN}");
1209/2000 worse, 791/2000 better, worst +24472 B at n=1025

List<OrderLineStruct> ที่​ไม่​ตั้ง capacity กิน​เท่า​กัน​หรือ​มากกว่า List<OrderLineClass> ใน 1209 จาก 2000 ขนาด และ n แรก​ที่​มัน​แพ้​คือ n=1 ส่วน​ช่องว่าง​ที่​แย่​ที่สุด​คือ +24472 B ที่ n=1025 ซึ่ง​เป็น​คู่ 98480 B เทียบ 74008 B ใน​ตาราง​ข้าง​บน​พอดี

กลไก​อ่าน​ออก​จาก​ซอร์ส​ของ List<T> ได้ตรงๆ ค่า​เริ่มต้น​คือ DefaultCapacity = 4 และ​ตอน​ขยาย​ใช้ newCapacity = 2 * _items.Length ทุก​ครั้ง​ที่​ขยาย มัน​จอง array ใหม่​ทั้ง​ก้อน​แล้ว​คัด​ลอก​ของ​เก่า​ลง​ไป สำหรับ array ของ struct ก้อน​ใหม่​นั้น​มี ตัว struct ทุก​ตัว​อยู่​ข้าง​ใน ส่วน array ของ class ก้อน​ใหม่​มี​แค่ reference ที่ 8 byte ต่อ​ช่อง ที่ n=1025 array จึง​ถูก​ขยาย​ไป​ถึง​ช่วง​ที่ struct ต้อง​จ่าย​เต็ม​ราคา​ซ้ำ​อีก​ครั้ง

สิ่ง​ที่​ควร​จำ​ไม่ใช่​เลข 1209 เลข​นั้น​ผูก​กับ​รูปร่าง​เฉพาะ​คู่​นี้ คือ struct 24 byte เทียบ class 40 byte สิ่ง​ที่​ควร​จำ​คือ​กลไก คือ​ความ​จุ​สะสม​ของ​การ​ขยาย​ที​ละ​สอง​เท่า เทียบ​กับ n ที่​ต้อง​การจริงๆ

defensive copy เป็น bug ความ​ถูกต้อง​ก่อน แล้ว​ค่อย​เป็น bug ความเร็ว

หัวข้อ​ที่​มีชื่อ​ว่า “defensive copy เป็น bug ความ​ถูกต้อง​ก่อน แล้ว​ค่อย​เป็น bug ความเร็ว”

เส้นทาง​ที่ code สั่ง​บวก​แล้ว​ผลลัพธ์​ออก​มา​เป็น​ศูนย์ โดย​ไม่มี error ไม่มี warning และ​ไม่มี​อะไร​ผิด​ปกติ​ให้​เห็น​เลย

ก่อน​จะ​พูด​เรื่อง​ราคา​ของ​สำเนา ต้อง​พูด​เรื่อง​ที่​แพง​กว่า​ก่อน คือ​กรณี​ที่​สำเนา​ทำให้ ผลลัพธ์​ผิด

defensive copydefensive copyสำเนา​ทั้ง​ก้อน​ที่ compiler แอบ​สร้าง​เมื่อ​เรียก​สมาชิก​ที่​ไม่ใช่ `readonly` ผ่าน `in` หรือ readonly field ทำให้การ​แก้​ค่า​ตกลง​ไป​ใน​สำเนา​แล้ว​หาย​ไป คือ​สำเนา​ทั้ง​ก้อน​ที่ compiler แอบ​สร้าง​ขึ้น​มา​เมื่อ​คุณ​เรียก​สมาชิก​ที่​ไม่ใช่ readonly ผ่าน​ทาง​ที่​มัน​ถือว่า​ห้าม​แก้ เช่น in parameter หรือ readonly field method นั้น​ทำงาน​จริง แต่​ทำงาน​กับ​สำเนา แล้ว​สำเนา​ก็​ถูก​ทิ้ง​ไป​เงียบๆ ตอน​จบ statement

code ข้าง​ล่าง​ตัด​เหลือ​ห้า​เส้นทาง ส่วน​ตาราง​ใต้ block รวม​ทั้ง​สิบ​เอ็ด​เส้นทาง​ที่​วัด

public struct MutableTally // ไม่ใช่ readonly struct
{
public int Count;
public long TotalSatang;
public void Add(long satang) { Count++; TotalSatang += satang; }
public readonly override string ToString() => $"Count={Count}, TotalSatang={TotalSatang}";
}
public sealed class Basket
{
public MutableTally Open; // field ปกติ
public readonly MutableTally Sealed_; // readonly field
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void FeedIn(in MutableTally t) => t.Add(18000); // compile ผ่าน แต่ค่าหาย
[MethodImpl(MethodImplOptions.NoInlining)]
static void FeedRef(ref MutableTally t) => t.Add(18000); // ค่าติด
var t1 = new MutableTally(); FeedIn(in t1); Console.WriteLine(t1);
var t2 = new MutableTally(); FeedRef(ref t2); Console.WriteLine(t2);
var b = new Basket(); b.Open.Add(18000); b.Sealed_.Add(18000);
Console.WriteLine($"{b.Open} | {b.Sealed_}");
var list = new List<MutableTally> { new(), new() };
var tmp = list[0]; tmp.Add(18000); Console.WriteLine(list[0]);
CollectionsMarshal.AsSpan(list)[0].Add(18000); Console.WriteLine(list[0]);

input เดียวกัน​ทุก​แถว​คือ Add(18000) หนึ่ง​ครั้ง​บน MutableTally ที่​เพิ่ง​สร้าง​ใหม่ ผล​ที่​พิมพ์​ออก​มา​แยก​เป็น​สอง​กลุ่มคมๆ

input ที่​วัดผล​ที่​พิมพ์​ออก​มา
FeedIn(in t1) — เรียก​ผ่าน in parameterCount=0, TotalSatang=0
b.Sealed_.Add(18000) — เรียก​ผ่าน readonly instance fieldCount=0, TotalSatang=0
เรียก​ผ่าน static readonly fieldCount=0, TotalSatang=0
((MutableTally)boxed).Add(18000)Count=0, TotalSatang=0
foreach element ที่​คัด​ลอก​มา​แล้ว​เรียก AddCount=0, TotalSatang=0
var tmp = list[0]; tmp.Add(18000); — indexer ของ List<T>Count=0, TotalSatang=0
FeedRef(ref t2) — เรียก​ผ่าน ref parameterCount=1, TotalSatang=18000
b.Open.Add(18000) — field ปกติCount=1, TotalSatang=18000
เรียก​ผ่าน static field ปกติCount=1, TotalSatang=18000
arr[i].Add(18000) บน arrayCount=1, TotalSatang=18000
CollectionsMarshal.AsSpan(list)[0].Add(18000)Count=1, TotalSatang=18000

สาม​แถว​ที่​อันตราย​ที่สุด​คือ readonly field, indexer ของ List<T> และ foreach เพราะ​ทั้ง​สาม​อัน​เป็น​สิ่ง​ที่​คน​เขียน​ด้วย​ความ​ตั้งใจ​ดี คน​ที่​ใส่ readonly ที่ field เพราะ​อยาก​ป้องกัน​ค่า ได้​ผล​ตรง​กัน​ข้าม​กับ​ที่​คิด​พอดี ยอด​รวม​ของ​ตะกร้า​ใบ​นั้น​กลาย​เป็น​ศูนย์​ถาวร

สังเกต​คู่​ที่​ต่าง​กัน​แค่​ตัว​เดียว b.Open.Add(18000) กับ b.Sealed_.Add(18000) อยู่​ใน class เดียวกัน บรรทัด​ติด​กัน ต่าง​กัน​แค่ keyword readonly ที่​ตัว field แล้ว​ผลลัพธ์​ออก​มา​คนละ​เรื่อง

compiler เตือน​ใน​เคส​ที่​คุณ​ไม่​เจอ และ​เงียบ​สนิท​ใน​เคส​ที่​คุณ​เจอ

หัวข้อ​ที่​มีชื่อ​ว่า “compiler เตือน​ใน​เคส​ที่​คุณ​ไม่​เจอ และ​เงียบ​สนิท​ใน​เคส​ที่​คุณ​เจอ”

รายการ diagnostic จริง​จาก dotnet build -c Release ซึ่ง​ฟ้อง​การ​เขียน field ตรงๆ ทุก​ทาง แต่​ปล่อย​การ​เรียก method ที่​เขียน field เดียวกัน​ผ่าน​หมด

public struct MutableTally
{
public int Count;
public long TotalSatang;
public void Add(long s) { Count++; TotalSatang += s; }
public long Average => Count == 0 ? 0 : TotalSatang / Count; // getter ไม่ใช่ readonly
public readonly long AverageTwice => Average * 2; // -> CS8656
}
public static class Cases
{
public static readonly MutableTally RoField;
public static void ThroughIn(in MutableTally t) => t.Add(18000); // เงียบสนิท
public static void ThroughReadonlyField() => RoField.Add(18000); // เงียบสนิท
public static long ReadThroughIn(in MutableTally t) => t.Average; // เงียบสนิท
// เทียบกับเคสที่ compiler ฟ้อง:
// public static void E1(in MutableTally t) { t.Count = 5; } // CS8332
// public static void E2() { RoField.Count = 5; } // CS1650
// public static void E3(List<MutableTally> l) { l[0].Count = 5; } // CS1612
}
input ที่​วัดdiagnostic ที่​ออก​มา​จริง
t.Add(18000) ผ่าน in parameterไม่มี​เลย
RoField.Add(18000) ผ่าน static readonly fieldไม่มี​เลย
t.Average (getter ที่​ไม่ใช่ readonly) ผ่าน inไม่มี​เลย
readonly member เรียก non-readonly memberwarning CS8656
t.Count = 5 ผ่าน in parametererror CS8332
RoField.Count = 5 ผ่าน static readonly fielderror CS1650
l[0].Count = 5 ผ่าน indexer ของ List<T>error CS1612
ใส่ field ที่​ไม่ใช่ readonly ไว้​ใน readonly structerror CS8340

ข้อความ​เต็ม​ของ​คำ​เตือน​เดียว​ที่​ออก​มา​คือ

warning CS8656: Call to non-readonly member 'MutableTally.Average.get' from a
'readonly' member results in an implicit copy of 'this'.

อ่าน​ตาราง​นี้​จาก​บน​ลง​ล่าง​แล้ว​จะ​เห็น​รูปแบบ​ที่​ชัด​มาก compiler ฟ้อง​ทุก​ครั้ง​ที่​คุณ เขียน field ตรงๆ ผ่าน​ทาง​ที่​มัน​ถือว่า readonly แต่​พอ​คุณ​เรียก method ที่​ไป​เขียน field เดียวกัน​นั้น มัน​ปล่อย​ผ่าน​เงียบ​สนิท

สาม​แถว​บน​สุด​คือ​สาม​เคส​ที่​จะ​เจอ​ใน code จริง และ​ทั้ง​สาม​ได้ 0 diagnostic ส่วน CS8656 ยิง​เฉพาะ​ตอน​ที่ readonly member เรียก non-readonly member ด้วย​กันเอง ซึ่ง​เป็น​รูปแบบ​ที่​พบ​น้อย​กว่า​มาก

ห้าม​ใช้​ผล​ของ compiler เป็น​เครื่อง​ยืนยัน​ว่า code ปลอด​จาก defensive copy

dotnet build -c Release ที่​ผ่าน​สะอาด​ไม่มี warning เลย ยัง​พา Count=0 จาก​หัวข้อ​ที่​แล้ว​เข้า​โปร​ดัก​ชัน​ได้​ทุก​เส้นทาง​ที่​ผ่าน​สำเนา

หมายเหตุ​ที่​ต้อง​พูด​พร้อม​กัน — ทีม​งาน​หา​หน้า​เอกสาร​ทางการ​ของ CS8656 แบบ compiler-messages ไม่​พบ ได้ 404 คำ​เตือน​ข้าง​บน​จึง​ยืนยัน​ได้​จาก​การ compile จริง​บน .NET SDK 10.0.302 เท่านั้น และ​บท​นี้​ไม่​แต่ง URL ขึ้น​มา​อ้าง

readonly struct กับ readonly member ลบ​สำเนา​ทิ้ง​ได้​จริง และ​เท่า​กัน​เป๊ะ

หัวข้อ​ที่​มีชื่อ​ว่า “readonly struct กับ readonly member ลบ​สำเนา​ทิ้ง​ได้​จริง และ​เท่า​กัน​เป๊ะ”

ภาษา​เครื่อง​ของ method สาม​ตัว​ที่​ทำงาน​เหมือน​กัน​ทุก​อย่าง ต่าง​กัน​แค่​ที่​ที่​วาง keyword readonly

ทาง​แก้​มี​สอง​ทาง คือ​ติด readonly ที่​ตัว struct ทั้ง​ก้อน หรือ​ติด​ที่​สมาชิก​ที​ละ​ตัว คำถาม​คือ​ทาง​ไหน​ดี​กว่า และ​คำ​ตอบ​ที่​วัด​ได้​คือ เท่า​กัน​เป๊ะ

code ข้าง​ล่าง​ตัด​เหลือ2 version ส่วน version ที่​สาม​ใน​ตาราง​คือ SnapRo ซึ่ง​เป็น field ชุด​เดียวกัน​แต่​ประกาศ​ทั้ง​ก้อน​เป็น readonly struct

public struct Snap // 112 byte, ไม่ใช่ readonly struct
{
public long OrderId, CustomerId, RestaurantId, RiderId;
public long PlacedAt, AcceptedAt, PickedUpAt, DeliveredAt;
public long Subtotal, DeliveryFee, Discount, Total;
public int Status, PaymentMethod, Rating, LineCount;
[MethodImpl(MethodImplOptions.NoInlining)]
public long GrandTotalOpaque() => Subtotal + DeliveryFee - Discount;
}
public struct SnapMember // เหมือนกัน แต่ member ติด readonly
{
public long OrderId, CustomerId, RestaurantId, RiderId;
public long PlacedAt, AcceptedAt, PickedUpAt, DeliveredAt;
public long Subtotal, DeliveryFee, Discount, Total;
public int Status, PaymentMethod, Rating, LineCount;
[MethodImpl(MethodImplOptions.NoInlining)]
public readonly long GrandTotalOpaque() => Subtotal + DeliveryFee - Discount;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public static long ReadOpaque_NonReadonly(in Snap s) => s.GrandTotalOpaque();
[MethodImpl(MethodImplOptions.NoInlining)]
public static long ReadOpaque_ReadonlyMember(in SnapMember s) => s.GrandTotalOpaque();
Terminal window
DOTNET_JitDisasm="Read*" dotnet bin/Release/net10.0/Disasm.dll
input ที่​วัดขนาด code ที่ JIT ปล่อยมี​สำเนา 112 byte ไหม
ReadOpaque_NonReadonly(in Snap)67 byteมี — sub rsp,120 แล้ว vmovdqu ymm0 สาม​คู่ กับ vmovdqu xmm0 หนึ่ง​คู่ ลง [rsp+0x08] แล้ว lea rdi,[rsp+0x08]
ReadOpaque_ReadonlyStruct(in SnapRo)13 byteไม่มี — ส่ง rdi ของ​ผู้​เรียก​ต่อไปตรงๆ
ReadOpaque_ReadonlyMember(in SnapMember)13 byteไม่มี — เท่ากับ​แถว​บน​เป๊ะ
ReadInline_NonReadonly (member ที่ inline ได้)19 byteไม่มี — อ่าน​จาก [rdi+...] ตรงๆ
ReadInline_ReadonlyStruct (member ที่ inline ได้)13 byteไม่มี — อ่าน​จาก [rdi+...] ตรงๆ

สาม​แถว​บน​คือ​คำ​ตอบ​ของ​หัวข้อ​นี้ 67 byte ที่​ประกอบ​ด้วย​การ​ยก​ข้อมูล 112 byte ขึ้น stack ก่อน​เรียก method ลด​เหลือ 13 byte เมื่อ​ติด readonly และ readonly structreadonly structstruct ที่​ประกาศ​ว่า​ทุก​สมาชิก​ไม่​แก้​สถานะ ทำให้ compiler เลิก​สร้าง defensive copy ทั้งหมด กับ readonly member ให้ 13 byte เท่า​กัน​ทั้ง​คู่ ไม่ใช่​ใกล้​เคียง​กัน แต่​เท่า​กันตัว​ต่อ​ตัว เลือก​ทาง​ไหน​ก็ได้​ตาม​ความ​สะดวก​ใน​การ​อ่าน code

สอง​แถว​ล่าง​คือ​แถว​ที่​ตำรา​ส่วน​ใหญ่​ไม่​พูด​ถึง เมื่อ​สมาชิก​นั้น inline ได้ JIT ลบ​สำเนา​ทิ้ง​ทั้ง​ก้อน​ถึง​แม้​จะ​ไม่มี readonly เลย 19 byte เทียบ 13 byte และ​ทั้ง​คู่​อ่าน​ค่า​จาก [rdi+...] ของ​ผู้​เรียก​โดยตรง ไม่มี​คำ​สั่ง copy สัก​คำ​สั่ง

ข้อ​สรุป​ที่​ใช้ได้​จริง​คือ defensive copy ไม่​ได้​ถูก​จ่าย​ทุก​ครั้ง มัน​ถูก​จ่าย​ตอน​ที่ JIT inline สมาชิก​นั้น​ไม่​ได้ ซึ่ง​เป็น​สถานการณ์​ที่​เกิด​กับ method ใหญ่ๆ method virtual และ method ที่​ข้าม assembly แต่​ไม่ใช่​กับ property getter สั้นๆ ที่ codebase ส่วน​ใหญ่​เต็ม​ไป​ด้วย

เหตุผล​ที่​ควร​ติด readonly จึง​ไม่ใช่ “เพราะ​ไม่​ติด​แล้ว​ช้า” แต่​เป็น​เหตุผล​จาก​หัวข้อ​ก่อนหน้า คือ​มัน​ปิด​ประตู​รูปแบบ​ที่​ทำให้ Count กลาย​เป็น​ศูนย์​เงียบๆ

สิ่ง​ที่​ข้อมูล​ชุด​นี้​ยัง​พิสูจน์​ไม่​ได้ และ​สิ่ง​ที่​วัด​แล้ว​แต่​ไม่​ผ่าน​เกณฑ์​จน​ต้อง​ตัด​ออก

  • บท​นี้​ไม่มี​ตัวเลข​เวลา​แม้แต่​ตัว​เดียว และ​นั่น​คือ​ความ​ตั้งใจ ชุด​วัด​เวลา​ของ​บท​นี้​รัน​แล้ว​ได้​ค่าที่​ไม่​ซ้ำ​กัน​ระหว่าง​รอบ มาก​เกิน​กว่า​จะ​ผ่าน​เกณฑ์​ของ​คอร์ส​ทั้ง​ที่​รัน​บน​เครื่อง​เดียวกัน​และ pin ที่​คอร์​เดียวกัน ตาม​กติกา​ข้อ 2 ของ​บท​ที่ 1 ตัวเลข​ที่​พ่วง​ค่า​การกระจาย​ที่​ยอมรับ​ได้​ไม่​ได้ ต้อง ตัด​ทิ้ง​ทั้ง​ประโยค ไม่ใช่​เขียน​ให้​เบา​ลง สิ่ง​เดียว​ที่​บท​นี้​อ้าง​จาก​ชุด​นั้น​ได้​คือ ลำดับ​ความเร็ว​ซ้ำ​ครบ​ทั้ง 5 รอบ บน​เครื่อง i7-7700 เครื่อง​นี้ ใน​กลุ่ม​ที่​ส่ง struct 112 byte และ​ใน​กลุ่ม defensive copy — ส่วน ตัวเลข ns ทุก​ตัว​ไม่​ถูก​ชิป
  • ห้าม​สรุป​ว่า struct 24 byte ที่​ส่ง by value เร็ว​กว่า​หรือ​ช้า​กว่า reference ของ class ช่วง​ที่​วัด​ได้​ทับ​กัน​สนิท ผล​คือ “แยก​ไม่​ออก” ซึ่ง​ไม่ใช่ “เท่า​กัน” และ​ไม่ใช่ “ชนะ”
  • ห้าม​อ้าง​ว่าการ​นับ allocation มอง​เห็น​ต้นทุน​การ​คัด​ลอก loop 10,000 รอบ​ทั้ง​สี่​แบบ​วัด​ได้ 0 B เท่า​กัน ทั้ง​ที่​แบบ by value ย้าย​ข้อมูล​ไป 1.12 MB
  • ห้าม​ใช้​ขนาด code แทน​ต้นทุน​การ​คัด​ลอก​เกิน​ราว 128 byte ที่ 256 byte JIT เปลี่ยน​ไป​ใช้ rep movsb ขนาด code จึง​ลด​ลง​เหลือ 80 byte ทั้ง​ที่​ข้อมูล​ที่​ต้อง​ย้าย​มาก​ขึ้น
  • ห้าม​อ้าง​ว่า defensive copy ถูก​จ่าย​เสมอ เมื่อ​สมาชิก​นั้น inline ได้ JIT ลบ​สำเนา​ทิ้ง​หมด
  • ห้าม​อ้าง​ว่า struct ชนะ class ใน List<T> เสมอ ชนะ​เฉพาะ​ตอน​ตั้ง capacity ที่ 0.50x–0.56x และ​แพ้ 1209 จาก 2000 ขนาด​เมื่อ​ไม่​ตั้ง
  • ผล​การ​สแกน 1209/2000 ผูก​กับ​รูปร่าง​เฉพาะ​คู่​นี้ คือ struct 24 byte เทียบ class 40 byte สิ่ง​ที่​ยก​ไป​ใช้​ต่อ​ได้​คือ​กลไก ไม่ใช่​ตัวเลข
  • ตัวเลข​ขนาด 24 / 40 / 112 / 128 / 16 byte ผูก​กับ x64 (IntPtr = 8) และ .NET 10.0.10 รวม​ถึง layout ของ class ที่ runtime ไม่​ได้​รับประกัน​ไว้
  • เลข 0 B ของ escape analysis ไม่ใช่​พฤติกรรม​ปกติ​ที่ app ทุก​ตัว​ได้​ฟรี ที่ tiering ปกติ round 0 อ่าน​ได้ 32 B และ​ต้อง​รอ​ถึง tier-1 หรือ​ปิด tiering ด้วย DOTNET_TieredCompilation=0 จึง​จะ​เห็น 0 B
  • TieredCompilation=false ใน​หัวข้อ codegen เป็น​สวิตช์​สำหรับ สังเกต ไม่ใช่​สำหรับ ชิป มัน​มี​ไว้​บังคับ​ให้ JIT ปล่อย code FullOpts ออก​มา​ให้​อ่าน ห้าม​ยก​ไป​ใส่ csproj ของ project ที่​จะ​เอา​ไป​วัด​จริง

สิ่ง​ที่​บท​นี้​พิสูจน์​ได้ วิธี​ตัดสิน​ใจ​ที่​ใช้ได้​จริง และ​ของ​ที่​บท​ถัด​ไป​จะ​รับช่วง​ต่อ

คำถาม “เปลี่ยน class เป็น struct แล้ว​เร็ว​ขึ้น​ไหม” ตอบ​ไม่​ได้​ด้วย keyword สิ่ง​ที่​วัด​ได้​บน​เครื่อง​นี้​คือ

  • struct ตัว​เดียวกัน​ไป​โผล่​บน heap ได้​สาม​ทาง เป็น field ของ class อ่าน​ได้ 40 B ถูก lambda จับ​อ่าน​ได้ 104 B และ​ถูก box อ่าน​ได้ 40 B
  • ที่ 16 byte ลงมา JIT ไม่​ออก​คำ​สั่ง copy เลย คำ​สั่ง copy โผล่​ครั้ง​แรก​ที่ 24 byte และ​ที่ 112 byte เป็น 14 คำ​สั่ง
  • List<struct> ที่​ไม่​ตั้ง capacity กิน​เท่า​กัน​หรือ​มากกว่า List<class> ใน 1209 จาก 2000 ขนาด แย่​สุด +24472 B ที่ n=1025
  • defensive copy ทำให้ Add(18000) หาย​ไป​เงียบๆ ใน​ทุก​เส้นทาง​ที่​ผ่าน​สำเนา โดย compiler ให้ 0 diagnostic ใน​สาม​เคส​ที่​พบ​บ่อย​ที่สุด
  • readonly struct กับ readonly member ให้ code 13 byte เท่า​กัน​เป๊ะ เทียบ​กับ 67 byte ของ version ที่​ไม่​ติด readonly

เอา​ไป​ใช้​เป็น​ลำดับ​การ​ตัดสินใจได้ตรงๆ ถาม​ว่า​ใคร​จะ​ถือ​มัน​ไว้​ก่อน ถ้า​คำ​ตอบ​คือ object บน heap struct ก็​ขึ้น heap ตาม​ไป​ด้วย · ถ้า​จะ​ส่ง​ต่อ​ไป​เรื่อยๆ และ​มัน​ใหญ่​กว่า 16 byte ให้​ส่ง​ด้วย in หรือ ref · ถ้า​มัน​เป็น struct ที่​มี method แก้​สถานะ ให้​ทำ​เป็น readonly struct ไป​เลย หรือ​ถ้า​ทำ​ไม่​ได้​ก็​อย่า​ให้​มัน​ไป​นั่ง​ใน readonly field · และ​ถ้า​จะ​เปลี่ยน List<class> เป็น List<struct> ให้​ตั้ง capacity ทุก​ครั้ง

และ​ข้อ​สุดท้าย​ซึ่ง​เป็น​ข้อ​ที่​บท​นี้​ยืนยัน​ด้วย​ตัวเอง คือ เครื่องมือ​วัด​หนึ่ง​ชิ้น​ตอบ​ได้​เฉพาะ​คำถาม​ที่​มัน​ออกแบบ​มา​ตอบ column Allocated ตอบ​คำถาม​ของ​บท​นี้​ไม่​ได้ และ​วิธี​ที่​ถูกต้อง​คือ​ประกาศ​ออก​มา​แล้ว​เปลี่ยน​เครื่องมือ ไม่ใช่​ปั้น​ภาพ​ให้​ดูเหมือน​มี​ข้อ​สรุป

บท​ที่ 3 กลับ​มา​อยู่​ใน​สนาม​ที่ column Allocated ตอบ​ได้​เต็ม​ปาก​อีก​ครั้ง เรื่อง boxing ซึ่ง​บท​นี้​แตะ​ไป​หนึ่ง​ครั้ง​แล้ว​ตอน​ที่ (object)line อ่าน​ได้ 40 B — และ​บท​หน้า​จะ​แสดง​ว่า​มัน​เป็น​คุณสมบัติ​ของ code ที่ JIT ปล่อย​ออก​มา ไม่ใช่​ของ​ซอร์ส​ที่​คุณ​เขียน


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • Structure types — C# reference (ms.date 2026-01-14 / อัปเดต 2026-08-07) — นิยาม​ของ readonly struct และ readonly instance member ซึ่ง​เป็น​สอง​ทาง​ที่​หัวข้อ​สุดท้าย​วัด​แล้ว​ได้ 13 byte เท่า​กัน
  • Choosing Between Class and Struct — Framework Design Guidelines (ms.date 2008-10-22 / อัปเดต 2023-10-03) — ที่มา​ของ​กฎ “instance size under 16 bytes” หน้า​นี้​แปะ​คำ​เตือน​เรื่อง​ความ​ล้าสมัย​ไว้​เอง และ​เส้น​แบ่ง​ที่​บท​นี้​วัด​จาก codegen ตรง​กับ​มัน​พอดี
  • Avoid memory allocations and data copies — C# (ms.date 2023-10-13 / อัปเดต 2026-03-30) — คำ​แนะนำ​ฝั่ง​เอกสาร​เรื่อง​การ​หลีก​เลี่ยง​การ​คัด​ลอก​ข้อมูล ซึ่ง​ใช้​เกณฑ์ “three words or less”
  • dotnet/runtime — ซอร์ส​ของ List<T> (ตรวจ​เมื่อ 2026-08-07) — DefaultCapacity = 4 และ newCapacity = 2 * _items.Length ซึ่ง​เป็น​กลไก​ที่​อธิบาย​ผล 1209/2000 ได้​ทั้งหมด
  • Performance Improvements in .NET 10 — .NET Blog (2025-09-10) — escape analysis กับ object stack allocation ที่​เป็น​เหตุผล​ว่า​ทำไม object class ที่​ไม่​หลุด​ออก​นอก method ถึง​อ่าน​ได้ 0 B ที่ tier-1

เช็กความเข้าใจ — บทที่ 2

ข้อ 1 / 3

loop 10,000 รอบสี่แบบ ทั้งส่ง OrderSnapshot 112 byte ทั้งก้อน ส่งด้วย in ส่ง reference ของ class และเส้นทาง defensive copy วัดได้ 0 B เท่ากันหมดทั้งสี่แบบ ข้อสรุปใดถูกต้อง