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

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

📦 kaen-food-ordering

บท​นี้​เดิน​บน 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 ไม่​ได้​ใน​ทาง​ที่​แย่​กว่า​เดิม คือ​แย่​แบบ​ที่​มอง​ไม่​เห็น ผม​จึง​พิมพ์​ลาย​นิ้ว​มือ​ให้​แทน แล้ว​บอก​ไว้​ตรง​นี้​ว่า​มัน​คือ​ลาย​นิ้ว​มือ ไม่ใช่​ตัว​ข้อมูล

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);
}
ส่วน​ของ v0input ที่​วัดB/batch
Split ที่​ตัด​ด้วย​ตัว​คั่น​ขีด​ตั้ง20 บรรทัด​ของ batch3832 B
NaiveMoney คือ ToString + PadLeft + Substring สอง​ครั้งยอด​ของ 20 บรรทัด บวก​ยอด​รวม2856 B
string.Format ที่​รับ object[] ⚠️21 บรรทัด​ของ​ใบเสร็จ2520 B — เป็น​ไป​ไม่​ได้ ดู​กล่อง​ข้าง​ล่าง
Encoding.UTF8.GetBytes(string)21 บรรทัด​ของ​ใบเสร็จ1512 B
List<IOrderLine> บวก box ทุก element20 element1408 B
รวม​ทั้ง methodbatch 20 บรรทัด → 982 byte12272 B

ห้า​บรรทัด​บน​คือ ราคา​ของ​แต่ละ​ส่วน​ที่​วัด​แยก ไม่ใช่​การ​ถอด​ยอด 12272 byte ออก​ให้​ครบ​ทุก byte คอร์ส​นี้​จึง​จะ​ไม่​บวก​มัน​ให้​ดู​แล้ว​อ้าง​ว่า​ลงตัว ประโยชน์​ของ​รายการ​นี้​อยู่​ที่​การ​จัด​ลำดับ​ว่า​ควร​แตะ​อะไร​ก่อน

⚠️ แถว string.Format ใน​ตาราง​ข้าง​บน​ผิด และ​พิสูจน์​ได้​ว่า​ผิด​โดย​ไม่​ต้อง​รู้​ว่า batch หน้าตา​ยังไง

บรรทัด​นี้​ถูก​เพิ่ม​ทีหลัง ตอน​ที่​มี​คน​พยายาม​กู้ 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/batchdelta จาก​ขั้น​ก่อน
v0 naiveBatch.Lines 20 บรรทัด → dest เป็น byte[] 8192 byte12272 B
v1 no-boxinginput ชุด​เดียวกัน​ทุก byte9880 B−2392 B
v2 span parseinput ชุด​เดียวกัน​ทุก byte6048 B−3832 B
v3 pooled bufferinput ชุด​เดียวกัน​ทุก byte1560 B−4488 B
v4 no-alloc formatinput ชุด​เดียวกัน​ทุก byte0 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=012312 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 เทียบ 12272 B

เลข 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 B0 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

เกร็ด​ที่​หักล้าง 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 ไว้

ข้อเท็จจริง​ที่​ทำให้ 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 ที่​วัดcharsUTF-8 byteGetMaxByteCount(chars)
"ต้มยำกุ้ง"92730
"Tom Yum Kung"121239
"ข้าวเหนียวมะม่วง"164851
batch ทั้ง​ชุด 20 บรรทัด464894

ทั้ง batch ขยาย​จาก 464 chars เป็น 894 byte คิด​เป็น​อัตราส่วน 1.93 เท่า ส่วน string อังกฤษ​ล้วน​อยู่​ที่​หนึ่ง​ต่อ​หนึ่ง กับดัก​นี้​จึง​เป็น​กับดัก​ที่​ผ่าน​เทส​ใน​ภาษา​อังกฤษได้สบายๆ แล้วไป​พัง​กับ​ข้อมูล​จริง

สังเกต​อีก​อย่าง​ที่​มี​ประโยชน์​คือ GetMaxByteCount ให้​ค่าที่​ปลอดภัย​แต่​เผื่อ​เยอะ​มาก​สำหรับ​ข้อความ​อังกฤษ คือ​ขอ 39 byte สำหรับ​ข้อความ​ที่​ใช้​จริง 12 byte ราคา​ของ​ความ​ถูกต้อง​คือ​พื้นที่​ที่​จอง​เกิน ซึ่ง​บน stack ก็​ยัง​คุ้ม​กว่า​การ​เดา

ราคา​ของ 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 B
ArrayPool<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 ทุก​บรรทัด

แถว​ของ BDNinput ที่​วัดAllocated
v0 naiveBatch.Lines 20 บรรทัด → _dest เป็น byte[] 8192 byte12272 B
v1input ชุด​เดียวกัน​ทุก byte9880 B
v2input ชุด​เดียวกัน​ทุก byte6048 B
v3input ชุด​เดียวกัน​ทุก byte1560 B
v4 no-alloc formatinput ชุด​เดียวกัน​ทุก byte0 B
v4-alt Utf8.TryWriteinput ชุด​เดียวกัน​ทุก byte0 B
v4-alt pooled scratchinput ชุด​เดียวกัน​ทุก byte0 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 ที่​ไม่​ได้​กัน​งาน​อื่น​ออก​ไป

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

⛔ ตัด byte ได้​ทั้งหมด ไม่​ได้​แปล​ว่า​ตัด​ปัญหา​ได้​ทั้งหมด

บันได​นี้​ตัด allocation จาก 12272 byte ต่อ batch ลง​เหลือ 0 byte นั่น​คือ ทั้งหมด และ​วัด​ซ้ำ​ได้

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

ย้ำ​อีก​ครั้ง​ว่า allocation ไม่​เท่ากับ residency คอร์ส​นี้​นับ byte ที่ จัดสรร ไม่​ได้​นับ byte ที่​ค้าง​อยู่ และ​ไม่มี​ข้อมูล GC pause, throughput, tail latency หรือ working set แม้แต่​ตัว​เดียว​ตลอด​ทั้ง​แปดบท

การ​อ่าน​บันได​เดิม​ด้วย​เกณฑ์​คนละ​อัน คือ​ความ​อ่าน​ง่าย​กับ​ความ​เสี่ยง ไม่ใช่​จำนวน byte

บันได​นี้​เขียน​ขึ้น​เพื่อ​พิสูจน์​ว่า​ไป​ถึง 0 ได้ ไม่ใช่​เพื่อ​เสนอ​ว่า code ทุก​ที่​ควร​หน้าตา​แบบ​นี้ ถ้า​ถาม​ว่า​ผม​จะ​เอา​ขั้น​ไหน​ไป​ใช้​จริง​บ้าง คำ​ตอบ​แบ่ง​ได้​ชัดเจน

v1 กับ v2 ทำ​เถอะ — v2 มี​จำนวน​บรรทัด​เท่ากับ v0 คือ 30 บรรทัด และ v1 สั้น​กว่า v0 ด้วย​ซ้ำ คือ 22 เทียบ 30 บรรทัด สอง​ขั้น​นี้​จึง​ไม่​ได้​แลก​อะไร​เลย ได้ byte กลับ​มา​โดย​ไม่​เสีย​ความ​อ่าน​ง่าย และ​ไม่​เพิ่ม​สิ่ง​ที่​ต้อง​จำ​ตอน​แก้ bug ตอน​ตี​สาม

v3 กับ v4 ต้อง​มี​เหตุผล​รองรับ​ก่อน ราคา​ของ​สอง​ขั้น​นี้​เขียน​ออก​มา​เป็น​รายการ​ได้

  • helper ที่​ต้อง​เขียน​เอง 41 บรรทัด คือ ParseLine 11 บรรทัด, WriteReceiptChars 12 บรรทัด, WriteTotalChars 9 บรรทัด และ WriteMoney 9 บรรทัด — 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 OrderLine 40 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 ด้วย ควรสรุปว่าอย่างไร