งาน string: กำจัด string กลางทาง (และเทคนิคที่ตำราบอกว่าช่วยแต่ไม่ช่วย)
code ที่อ่านบรรทัดออเดอร์แล้วเอาชื่อเมนูไปเปิดตารางราคา สร้าง string ขึ้นมาก้อนหนึ่งต่อบรรทัดเพื่อใช้เป็น key แล้วทิ้งมันทันทีที่ dictionary หา hash เสร็จ string ก้อนนั้นไม่มีใครอ่าน ไม่มีใครเก็บ และมันคือค่าใช้จ่ายหลักของงาน string ใน code จริง
บทนี้ไล่ทีละเทคนิคว่าจะกำจัด string กลางทางพวกนั้นได้อย่างไร และผลที่วัดออกมาไม่ได้เข้าข้างตำราเสมอไป string.Create ที่หลายคนเขียนด้วยมือเพื่อ “ประหยัดกว่า interpolation” วัดบนบรรทัดออเดอร์ 22 อักขระบรรทัดเดียวได้ 72 B เท่ากันเป๊ะทั้งคู่ ส่วน new StringBuilder() ที่ถูกหยิบมาใช้ทันทีที่เห็นการต่อ string ใน loop วัดบนใบเสร็จหกบรรทัดได้ 1200 B ซึ่งแพงกว่าการต่อด้วย += ที่ 1168 B บน input ชุดเดียวกัน
ทั้งบทเดินบน hot path เส้นเดิมของ domain Order ที่คอร์ส #8–#11 สร้างไว้ คือ parse บรรทัดออเดอร์ → รวมยอด → พิมพ์ใบเสร็จ (repo ตัวอย่าง .NET กำลังจัดทำ) ทุก snippet รันได้จริงบน .NET SDK 10 และทุกตัวเลขที่พิมพ์ออกมาคือค่าที่วัดได้จริงบนเครื่องเดียวกับบท 1
ทุกตารางในบทนี้มี column input ที่วัด เสมอ เพราะตัวเลขของบทนี้เป็น function ของข้อมูลตั้งต้นมากกว่าบทไหนในคอร์ส byte count ที่ไม่พิมพ์ input กำกับคือตัวเลขที่ reproduce ไม่ได้
ไวยากรณ์ C# 11–14 ที่ใช้ในบทนี้ (collection expression, UTF-8 string literal "…"u8, range operator, params ReadOnlySpan<T>) คอร์สนี้ ใช้ โดยไม่หยุดอธิบาย ถ้าเจอของที่ไม่คุ้นให้เปิด C# 8 → 14: อะไรเปลี่ยนไปบ้าง ควบคู่ไป
code ชุดเดียวกัน ชื่อเมนูคนละชุด ได้ 280 B กับ 272 B และทั้งคู่ถูก
หัวข้อที่มีชื่อว่า “code ชุดเดียวกัน ชื่อเมนูคนละชุด ได้ 280 B กับ 272 B และทั้งคู่ถูก”หลักฐานที่บทนี้ต้องอธิบายก่อนผู้อ่านจะสังเกตเอง เพราะตัวเลขในเนื้อความกับตัวเลขในภาพประกอบหน้าเดียวกันไม่เท่ากัน และเหตุผลของมันคือใจความของบทนี้
code ที่วัดคือการเอาชื่อเมนูจากบรรทัดออเดอร์ไปเปิดตารางราคา สองแบบที่ให้ผลเท่ากันทุกบาททุกสตางค์ แบบ A สร้าง string key ขึ้นมาก่อน แบบ B ไม่สร้างอะไรเลย
static readonly string[] Lines =[ "1042|ต้มยำกุ้ง|2|18000", "1043|ผัดไทยกุ้งสด|1|12000", "1044|ข้าวผัดปู|3|15000", "1045|ส้มตำไทย|2|8000", "1046|แกงเขียวหวานไก่|1|14000", "1047|มะม่วงข้าวเหนียว|4|9000",];
static (int Start, int Len) DishField(string line){ int a = line.IndexOf('|'); int b = line.IndexOf('|', a + 1); return (a + 1, b - a - 1);}
// A — สร้าง string key ขึ้นมาหนึ่งก้อนต่อบรรทัดstatic int SumViaSubstring(Dictionary<string, int> menu){ int sum = 0; foreach (var line in Lines) { var (s, l) = DishField(line); sum += menu[line.Substring(s, l)]; } return sum;}
// B — ไม่ materialise key เลยสักก้อนstatic int SumViaAlternate(Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> lookup){ int sum = 0; foreach (var line in Lines) { var (s, l) = DishField(line); sum += lookup[line.AsSpan(s, l)]; } return sum;}| วิธี | input ที่วัด | Allocated ต่อหกบรรทัด |
|---|---|---|
line.Substring(s, l) เป็น key | หกบรรทัดของ Lines ข้างบน | 280 B |
lookup[line.AsSpan(s, l)] | หกบรรทัดชุดเดียวกัน | 0 B |
alternate lookup บน FrozenDictionary | หกบรรทัดชุดเดียวกัน | 0 B |
line.Split('|')[1] เป็น key | หกบรรทัดชุดเดียวกัน | 1144 B |
280 B นั้นแยกออกเป็นก้อนที่วัดได้ทีละบรรทัด และขนาดของแต่ละก้อนขึ้นกับความยาวชื่อเมนู ไม่ได้ขึ้นกับ code
| input ที่วัด (ชื่อเมนู) | จำนวน char UTF-16 | Allocated |
|---|---|---|
"ต้มยำกุ้ง" | 9 | 40 B |
"ผัดไทยกุ้งสด" | 12 | 48 B |
"ข้าวผัดปู" | 9 | 40 B |
"ส้มตำไทย" | 8 | 40 B |
"แกงเขียวหวานไก่" | 15 | 56 B |
"มะม่วงข้าวเหนียว" | 16 | 56 B |
| รวม | 280 B |
ทีนี้เลื่อนลงไปดูภาพประกอบเชิงโต้ตอบข้างล่าง มันแสดงฉากเดียวกันเป๊ะ คือ Substring key เทียบกับ alternate lookup แต่มันขึ้นเลข 272 B ไม่ใช่ 280 B
สองตัวเลขนี้มาจาก code ชุดเดียวกันและเทคนิคเดียวกัน ต่างกันแค่ ชื่ออาหารในบรรทัดออเดอร์
ชุดของบทเรียนนี้ (M6.1) ยาว 9 / 12 / 9 / 8 / 15 / 16 อักขระ UTF-16
"ต้มยำกุ้ง" "ผัดไทยกุ้งสด" "ข้าวผัดปู" "ส้มตำไทย" "แกงเขียวหวานไก่" "มะม่วงข้าวเหนียว" → 40 + 48 + 40 + 40 + 56 + 56 = 280 B
ชุดของภาพประกอบ (ชุดข้อมูลของ island เอง ไม่ใช่ของ M6.1) ยาว 12 / 9 / 10 / 8 / 15 / 9 อักขระ UTF-16
"ผัดไทยกุ้งสด" "ต้มยำกุ้ง" "ข้าวมันไก่" "ส้มตำไทย" "แกงเขียวหวานไก่" "ชาไทยเย็น" → 48 + 40 + 48 + 40 + 56 + 40 = 272 B
ทั้งสองเลขถูกทั้งคู่ ตัวเลขนี้เป็น function ของ ความยาวชื่อเมนู ไม่ใช่ของ เทคนิค เปลี่ยนเมนูในร้าน ตัวเลขก็เปลี่ยน — สิ่งที่ไม่เปลี่ยนคือฝั่งขวาที่อ่านได้ 0 B ทั้งสองชุด
ตัวเลขของ บทเรียนนี้คือ 280 B ส่วน 272 B เป็นตัวเลขที่ถูกต้องสำหรับ input ของ island เท่านั้น ห้ามสลับกัน และนี่คือเหตุผลที่ทุกตารางในบทนี้มี column input ติดมาด้วย
เทียบเลนหน่วยความจำ
6 / 6 ออเดอร์.NET 10.0.10 · X64บทที่ 6 · ชื่อเมนูอยู่กลางบรรทัด ต้องเอาไปหาราคาใน Dictionary — จะทำ key อย่างไร
แกนของกล่องนี้คือ จำนวนออเดอร์ที่ประมวลผลไปแล้ว ไม่ใช่เวลา — จังหวะของปุ่ม “เดินอัตโนมัติ” เป็นค่าคงที่ที่ตั้งไว้เพื่อให้ดูทัน ไม่ได้แทนความเร็วจริงของ code และหน้านี้ไม่แสดงเวลาที่ไหนเลย
ออเดอร์ล่าสุดที่ป้อนเข้าไป: 1047,ชาไทยเย็น,4,9000
code เดิม Substring
คัดลอกชื่อเมนูออกมาเป็น string ใหม่เพื่อใช้เป็น key
code ที่ถูกวัด
public static int PriceViaSubstring(string line)
{
int c1 = line.IndexOf(',');
int c2 = line.IndexOf(',', c1 + 1);
string menu = line.Substring(c1 + 1, c2 - c1 - 1);
return Menu[menu];
}272byte ที่จองบน heap
6 ก้อน · string 6 · ออเดอร์ล่าสุดเพิ่ม +40 B
heap หลังผ่านไป 6 ออเดอร์
code ใหม่ GetAlternateLookup
ค้นด้วย ReadOnlySpan<char> ตรง ๆ ไม่ต้องมี key จริง
code ที่ถูกวัด
public static int PriceViaSpanLookup(string line)
{
var lookup = Menu.GetAlternateLookup<ReadOnlySpan<char>>();
ReadOnlySpan<char> s = line;
int c1 = s.IndexOf(',');
ReadOnlySpan<char> rest = s[(c1 + 1)..];
int c2 = rest.IndexOf(',');
return lookup[rest[..c2]];
}0byte ที่จองบน heap
ไม่มีก้อนไหนถูกจองเลย · ออเดอร์ล่าสุดเพิ่ม 0 B
heap หลังผ่านไป 6 ออเดอร์
heap ว่าง — 0 ก้อน · 0 byte
ก้อนพวกนี้ ตายเกือบทันที หลังถูกจอง ราคาที่จ่ายจึงเป็น GC pressure กับ cache traffic ไม่ใช่ RAM ที่ถูกยึดค้างไว้ — กล่องนี้วัด allocation ไม่ใช่ residency
หนึ่งช่องบนตาราง heap = 8 byte (ความละเอียดจริงของ heap บน X64) · ▦ อาร์เรย์ · ▨ string · ▩ กล่อง
string ที่จองตรงนี้มีอายุสั้นมาก — เกิดขึ้นเพื่อเป็น key ให้ Dictionary หา hash เสร็จแล้วก็ถูกทิ้งทันที alternate lookup (มีตั้งแต่ .NET 9) ให้ค้นด้วย span ได้ตรง ๆ เพราะ StringComparer.Ordinal บอกวิธี hash span ให้ตรงกับ string ได้อยู่แล้ว จึงไม่ต้องสร้าง key ชั่วคราวขึ้นมาเลย
ราคาที่จ่าย ต้องใช้ comparer ที่รองรับ (StringComparer.Ordinal ได้ · comparer เริ่มต้นของ Dictionary<string,…> ก็ได้) ถ้าใช้ comparer แบบอื่นจะเรียกไม่ผ่านตั้งแต่ตอน compile และรูปแบบนี้ยังไม่มีใน framework ก่อน .NET 9
ตัวเลขที่วัดได้ทั้งหมดของฉากนี้ (byte)
| ออเดอร์ | Substring | GetAlternateLookup | ก้อนที่เกิดขึ้น |
|---|---|---|---|
| ผัดไทยกุ้งสด | 48 | 0 | ▨ "ผัดไทยกุ้งสด" 48 B |
| ต้มยำกุ้ง | 40 | 0 | ▨ "ต้มยำกุ้ง" 40 B |
| ข้าวมันไก่ | 48 | 0 | ▨ "ข้าวมันไก่" 48 B |
| ส้มตำไทย | 40 | 0 | ▨ "ส้มตำไทย" 40 B |
| แกงเขียวหวานไก่ | 56 | 0 | ▨ "แกงเขียวหวานไก่" 56 B |
| ชาไทยเย็น | 40 | 0 | ▨ "ชาไทยเย็น" 40 B |
| รวมหกออเดอร์ | 272 | 0 | ผลลัพธ์ที่คำนวณได้เท่ากันทั้งสองเลน (87,500) |
วนซ้ำหลายรอบแล้ววัดใหม่ทั้งหมด — ไม่ได้เอาเลขข้างบนไปคูณ
| จำนวนรอบ | 1 | 10 | 100 | 1,000 |
|---|---|---|---|---|
| Substring | 272 | 2,720 | 27,200 | 272,000 |
| GetAlternateLookup | 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คำบรรยายภาพ: เลนซ้ายคือทางที่สร้าง string key หนึ่งก้อนต่อออเดอร์ เลนขวาคือทางที่ค้นด้วย span ตรงๆ · แกนนอนคือ จำนวนออเดอร์ที่ประมวลผลไปแล้ว ไม่ใช่เวลา · ยอดสะสม 272 B ของเลนซ้ายเป็นของชุดชื่อเมนูหกตัวในกล่องนี้ ส่วนชุดชื่อเมนูของเนื้อความบทนี้ให้ 280 B ตามที่อธิบายไว้ข้างบน
ทำไมความยาวชื่อเมนูถึงกลายเป็น byte แบบขั้นบันได
หัวข้อที่มีชื่อว่า “ทำไมความยาวชื่อเมนูถึงกลายเป็น byte แบบขั้นบันได”ตารางฐานที่ใช้ถอดรหัสตัวเลขอื่นเกือบทุกตัวในบทนี้ คือขนาดของ object string ตามจำนวนอักขระ
static object? _sink; // จอดผลลัพธ์ไว้ กัน escape analysis ของ .NET 10 ย้ายมันขึ้น stack แล้วตัวเลขหายไปเงียบๆ
foreach (int len in new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 22 }){ string src = new string('x', len); _sink = new string(src.AsSpan());}| input ที่วัด | Allocated |
|---|---|
new string('x', 1) คัดลอกใหม่ | 24 B |
| ความยาว 2 ถึง 5 | 32 B |
| ความยาว 6 ถึง 9 | 40 B |
| ความยาว 10 | 48 B |
| ความยาว 22 | 72 B |
ทั้ง 11 จุดที่วัดเข้ากับสูตร 24 + 8*ceil((len-1)/4) ผลคือความยาวที่ต่างกันหนึ่งอักขระอาจไม่ทำให้ byte เปลี่ยนเลย แต่พอข้ามขั้นก็กระโดดทีละ 8 B
นี่คือเหตุผลที่เลขสามหลักอย่าง "300" เสีย 32 B และบรรทัดออเดอร์ 22 อักขระเสีย 72 B และเป็นเหตุผลที่ชุดชื่อเมนูสองชุดข้างบนให้ผลรวมต่างกัน 8 B ทั้งที่มีหกก้อนเท่ากัน
ตัวเลขทั้งบทผูกกับ x64 (IntPtr = 8) บนสถาปัตยกรรมอื่นตารางนี้เปลี่ยนทั้งตาราง
ตัวเลขผีจากเครื่องวัดที่ยังไม่ถึง tier-1
หัวข้อที่มีชื่อว่า “ตัวเลขผีจากเครื่องวัดที่ยังไม่ถึง tier-1”method ชุดเดียวกันในไบนารีเดียวกัน รายงาน 72 B ตอนต้น process และ 0 B หลังถูกเลื่อนขั้น พร้อมตัวเลขทั้งคู่ตามที่คอร์สนี้บังคับไว้
ก่อนจะเชื่อตัวเลขไหนในบทนี้ ต้องรู้ก่อนว่าเครื่องวัดที่อุ่นไม่พอ ไม่ได้ รายงานว่าวัดไม่ได้ มันรายงานตัวเลขที่นิ่ง ซ้ำได้ และผิด
// ไบนารีเดียวกัน วัดสองแบบ// แบบที่ 1: อุ่นแค่ 2000 รอบติดกันแล้ววัดทันที -> ได้เลข tier-0// แบบที่ 2: อุ่นจนพ้น call-counting delay จนโปรบสามครั้งติดกันเท่ากัน -> ได้เลข tier-1Span<byte> buf = stackalloc byte[256];System.Text.Unicode.Utf8.TryWrite(buf, $"{id}|{dish}|{qty}|{satang}", out int written);| input ที่วัด | tier-0 | tier-1 |
|---|---|---|
Utf8.TryWrite บรรทัดออเดอร์เต็ม 1042 ต้มยำกุ้ง 2 18000 | 72 B | 0 B |
$"{satang / 100m:0.00}" ส่วนเกินของช่อง decimal | +32 B | 0 B |
$"{DateTime:yyyy-MM-dd}" ส่วนเกินของช่อง DateTime | +24 B | 0 B |
new char[] { 'a', 'b', 'c' } | 104 B | 32 B |
string.IndexOfAny(new char[] { … }) | 104 B | 0 B ส่วนเกิน (ยังจ่ายค่าอาร์เรย์แถวบน) |
72 B ของ tier-0 ไม่ได้มาจากไหนเลย มันคือ 3 boxed int × 24 B พอดี — ที่ tier-0 ช่อง {id} {qty} {satang} ยังถูก box จริงๆ พอเลื่อนขั้นเป็น tier-1 การ box ทั้งสามหายไป
ประเด็นที่คมที่สุดคือ ค่าที่ tier-0 นั้นเองก็นิ่งเท่ากันทั้งสี่รัน ใครที่รันซ้ำแล้วได้ 72 B เหมือนเดิมทุกครั้งจะสรุปว่าตัวเลขนี้เชื่อได้ ทั้งที่มันเป็นตัวเลขของ code ที่ยังไม่ถูกเลื่อนขั้น “มัน reproduce ได้” จึงไม่พอที่จะทำให้มันถูก
ตัวเลขที่เหลือในบทนี้วัดจาก harness ที่อุ่นถึง tier-1 แล้วทั้งหมด และคู่ tier-0/tier-1 ข้างบนคือเหตุผลที่ต้องบอกด้วยว่า process อายุสั้น cold path และ serverless จ่ายเลข column ซ้ายจริง ไม่ใช่ว่า column ซ้ายเป็นของปลอม
int.ToString() ไม่ได้จองเสมอ เส้นแบ่งอยู่ที่ 300 พอดี
หัวข้อที่มีชื่อว่า “int.ToString() ไม่ได้จองเสมอ เส้นแบ่งอยู่ที่ 300 พอดี”ตัวเลขเดียวกันสองค่าที่ต่างกันแค่หนึ่งหน่วย ให้ 0 B กับ 32 B และเส้นแบ่งนั้นอ่านได้จากซอร์สของ runtime
static object? _sink;
static long BytesPerToString(int value, int iters){ long b0 = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < iters; i++) _sink = value.ToString(); return (GC.GetAllocatedBytesForCurrentThread() - b0) / iters;}| input ที่วัด | Allocated ต่อครั้ง |
|---|---|
0.ToString() ถึง 299.ToString() | 0 B |
300.ToString() | 32 B |
(-1).ToString() (-100).ToString() (-299).ToString() (-300).ToString() | 32 B |
((uint)7).ToString() และ ((long)7).ToString() | 0 B |
((uint)300).ToString() และ ((long)300).ToString() | 32 B |
7.ToString("D") | 0 B |
7.ToString("D3") | 32 B |
ค่าถึง 99999 | 32 B |
100000 ถึง 1000000 | 40 B |
int.MaxValue.ToString() | 48 B |
ยืนยันด้วย identity ไม่ใช่แค่ตัวนับ byte ReferenceEquals(299.ToString(), 299.ToString()) = True ส่วน ReferenceEquals(300.ToString(), 300.ToString()) = False และ ReferenceEquals(2.ToString(), "2") = False ซึ่งบอกว่านี่คือ cache ของ runtime ไม่ใช่ intern pool ของ string ลิเทอรัล
ผลจริงบน hot path ของ domain Order คือ orderId ที่เป็น 1042 เสีย 32 B ทุกครั้งที่ถูกแปลงเป็น string ขณะที่ qty ที่เป็น 2 ฟรี — และการเปลี่ยน qty จาก 2 เป็น 300 ทำให้บรรทัดนั้นแพงขึ้นโดยที่ code ไม่ได้เปลี่ยนสักตัวอักษร
เส้นแบ่งนี้วัดบน CoreCLR linux-x64 .NET 10.0.10 เท่านั้น กลไกยืนยันจากซอร์ส dotnet/runtime สาขา release/10.0 file Number.Formatting.cs ที่ประกาศ private const int SmallNumberCacheLength = 300
ซอร์ส file เดียวกันระบุว่า บน Mono ค่านี้คือ 10 ซึ่งบทนี้ ไม่ได้วัดเอง — อ่านจากซอร์สอย่างเดียว แปลว่า code ชุดเดียวกันบน MAUI / Android / WASM จะเจอกับดักเดียวกันที่เส้นแบ่งคนละที่
สิ่งที่เอาไปใช้ได้คือ “อย่าเดาว่า int.ToString() ฟรีเสมอ และอย่าเดาว่ามันเสียเสมอ ให้วัด” ไม่ใช่ “เขียน code ให้ค่าต่ำกว่า 300”
ประกอบบรรทัดออเดอร์: ตราบใดที่ปลายทางยังเป็น string พื้นคือ 72 B
หัวข้อที่มีชื่อว่า “ประกอบบรรทัดออเดอร์: ตราบใดที่ปลายทางยังเป็น string พื้นคือ 72 B”เจ็ดวิธีประกอบข้อความเดียวกันเรียงกัน ซึ่งพิสูจน์ว่า
string.Createที่เขียนด้วยมือไม่ได้ประหยัดกว่า interpolation แม้แต่ byte เดียว
input ของทุกแถวคือค่าชุดเดียวกัน id = 1042 dish = "ต้มยำกุ้ง" qty = 2 satang = 18000 และผลลัพธ์ที่ต้องได้คือ string 22 อักขระ 1042 ต้มยำกุ้ง 2 18000 (คั่นด้วยขีดตั้ง)
string A() => $"{id}|{dish}|{qty}|{satang}";string B() => id + "|" + dish + "|" + qty + "|" + satang;string C() => string.Format("{0}|{1}|{2}|{3}", id, dish, qty, satang);
string D(){ var sb = new StringBuilder(); sb.Append(id).Append('|').Append(dish).Append('|').Append(qty).Append('|').Append(satang); return sb.ToString();}
string E(){ int len = CountDigits(id) + 1 + dish.Length + 1 + CountDigits(qty) + 1 + CountDigits(satang); return string.Create(len, (id, dish, qty, satang), static (span, s) => { s.id.TryFormat(span, out int w); span = span[w..]; span[0] = '|'; span = span[1..]; s.dish.CopyTo(span); span = span[s.dish.Length..]; span[0] = '|'; span = span[1..]; s.qty.TryFormat(span, out w); span = span[w..]; span[0] = '|'; span = span[1..]; s.satang.TryFormat(span, out _); });}
// F — ไม่สร้าง string เลย: เขียนลง buffer ที่ผู้เรียกถืออยู่แล้วint F(Span<char> buf){ int p = 0; id.TryFormat(buf, out int w); p += w; buf[p++] = '|'; dish.CopyTo(buf[p..]); p += dish.Length; buf[p++] = '|'; qty.TryFormat(buf[p..], out w); p += w; buf[p++] = '|'; satang.TryFormat(buf[p..], out w); p += w; return p;}| input ที่วัด | Allocated |
|---|---|
string ผลลัพธ์ 22 อักขระเปล่าๆ (new string(span)) | 72 B |
$"{id}|{dish}|{qty}|{satang}" | 72 B |
string.Create(len, …) เขียนเองทั้งก้อน | 72 B |
stackalloc แล้ว new string(span) | 72 B |
string.Format("{0}|{1}|{2}|{3}", …) | 144 B |
id + "|" + dish + "|" + qty + "|" + satang | 216 B |
new StringBuilder() แล้ว Append แล้ว ToString | 280 B |
TryFormat ลง Span<char> ที่ผู้เรียกถือ | 0 B |
Utf8Formatter ลง Span<byte> | 0 B |
Utf8.TryWrite($"…") ลง Span<byte> | 0 B |
สามแถวแรกให้ 72 B เท่ากันเป๊ะ และ 72 B นั้นคือขนาดของ string 22 อักขระตามตาราง M6.11 พอดี ตราบใดที่ปลายทางยังเป็น string 72 B คือพื้น จะเขียน string.Create ด้วยมืออีกกี่บรรทัดก็ลงต่ำกว่าพื้นไม่ได้
interpolated string handlerinterpolated string handlerโครงที่ compiler แปลง `$"…"` ไปเรียกแทนการต่อ string ทำให้ช่องที่เป็น int ไม่เสียส่วนเกิน แต่ยัง box struct ที่ไม่ implement `ISpanFormattable` ทำให้ $"…" ไม่แพงกว่าพื้นเลยในเคสนี้ คนที่เขียน string.Create ด้วยมือเพื่อ “ประหยัดกว่า interpolation” จึงจ่ายด้วย code ที่อ่านยากขึ้นสิบเท่าเพื่อแลกกับ0 byte
แถว string.Format ที่ 144 B ต้องอ่านคู่กับบท 3 ซึ่งวัด string.Format ที่4 argument เหมือนกัน แล้วได้ 176 B ตัวเลขไม่ได้ขัดกัน เพราะทั้งคู่คือ “string ผลลัพธ์ + box หนึ่งก้อนต่อช่องที่เป็น value type” ตามที่บท 3 ถอดไว้ ของบท 3 คือผลลัพธ์ 28 อักขระ 80 B บวกช่อง int สี่ช่อง ส่วนของบทนี้คือผลลัพธ์ 22 อักขระ 72 B บวกช่อง int สามช่อง เพราะช่อง dish เป็น string อยู่แล้วจึงไม่ถูก box — อีกครั้งหนึ่งที่ byte เป็น function ของ input ไม่ใช่ของ API
ทางเดียวที่ลงต่ำกว่า 72 B ได้คือ ไม่สร้าง string ซึ่งคือสามแถวล่างสุด — และนั่นแปลว่าต้องเปลี่ยนสัญญาของ method จากคืน string เป็นรับ buffer เข้ามาแล้วคืนจำนวนอักขระที่เขียน
flowchart TD
A[ต้องประกอบข้อความจากบรรทัดออเดอร์] --> B{ปลายทางต้องเป็น string จริงหรือไม่}
B -- ต้องเป็น string --> C[พื้นคือขนาดของ string นั้นเอง 22 อักขระ เท่ากับ 72 B]
C --> D[interpolation 72 B และ string.Create 72 B เท่ากัน เลือกตัวที่อ่านง่ายกว่า]
C --> E[เลี่ยงทางที่แพงกว่าพื้น string.Format 144 B ต่อด้วยเครื่องหมายบวก 216 B StringBuilder ใหม่ 280 B]
B -- ปลายทางเป็น buffer --> F[เขียนลง Span ที่ผู้เรียกถืออยู่แล้ว]
F --> G[TryFormat ลง Span ของ char ได้ 0 B และ Utf8Formatter ลง Span ของ byte ได้ 0 B]
G --> H[ราคาที่จ่ายคือสัญญาของ method เปลี่ยน จากคืน string เป็นรับ buffer แล้วคืนจำนวนอักขระ]
คำบรรยายภาพ: เส้นทางตัดสินใจของงานประกอบข้อความหนึ่งบรรทัด · ถ้าปลายทางยังต้องเป็น string ทุกเทคนิคจะชนพื้นเดียวกันที่ 72 B และเหลือแค่เรื่องความอ่านง่าย · ทางที่ลงถึง 0 B ได้มีทางเดียวคือเลิกสร้าง string แล้วเขียนลง buffer ของผู้เรียก ซึ่งแลกมาด้วยสัญญาของ method ที่เปลี่ยนไป
216 B ของเครื่องหมายบวก แยกเป็นก้อนที่วัดได้ทีละก้อน
หัวข้อที่มีชื่อว่า “216 B ของเครื่องหมายบวก แยกเป็นก้อนที่วัดได้ทีละก้อน”แถว 216 B ข้างบนไม่ใช่ค่าที่ลอยมา วัดแต่ละก้อนแยกกันแล้วบวกได้เท่ากันพอดี
| input ที่วัด | Allocated |
|---|---|
(1042).ToString() | 32 B |
(2).ToString() — อยู่ใน cache เพราะต่ำกว่า 300 | 0 B |
(18000).ToString() | 32 B |
new string[7] — params array | 80 B |
| string ผลลัพธ์ 22 อักขระ | 72 B |
| รวม | 216 B |
ก้อนที่คนไม่คาดคือ new string[7] ขนาด 80 B ซึ่งเป็น params array ที่ compiler สร้างให้ และมันโผล่ที่หน้าผาจำนวนตัวดำเนินการพอดี
| input ที่วัด | Allocated |
|---|---|
a + b (2 ตัวดำเนินการ) | 32 B |
a + b + c (3 ตัว) | 56 B |
a + b + c + d (4 ตัว) | 56 B |
a + b + c + d + e (5 ตัว) | 120 B |
a + b + c + d + e + f (6 ตัว) | 128 B |
a + b + c + d + e + f + g (7 ตัว) | 152 B |
string.Concat(a, b, c, d, e, f, g) | 72 B |
string.Concat(new string[] { a, …, g }) | 152 B |
สองสามสี่ตัวดำเนินการเสียแค่ string ผลลัพธ์ ตั้งแต่ห้าตัวขึ้นไปส่วนที่เพิ่มเท่ากับขนาดของ new string[N] พอดี คือ +64 B ที่ห้าตัว +72 B ที่หกตัว และ +80 B ที่เจ็ดตัว ซึ่งตรงกับที่วัด new string[5] new string[6] new string[7] แยกออกมา
เรียก string.Concat ด้วย argument ชุดเดียวกันได้ 72 B คือ string ผลลัพธ์ล้วนๆ เพราะมัน bind กับ overload params ReadOnlySpan<string> ส่วนการส่ง array เข้าไปตรงๆ กลับไปเสีย 152 B เท่าเดิม
ใบเสร็จหกบรรทัด: StringBuilder แพ้ทั้ง += และ string.Join
หัวข้อที่มีชื่อว่า “ใบเสร็จหกบรรทัด: StringBuilder แพ้ทั้ง += และ string.Join”เคสที่ตำราแนะนำ StringBuilder ชัดที่สุด คือการต่อ string ใน loop และเป็นเคสที่ StringBuilder ตัวใหม่วัดแล้วแพ้ทุกตัวเลือกยกเว้นการไม่ทำอะไรเลย
input คือหกบรรทัด id|dish|qty|satang ที่ประกอบเสร็จแล้ว ต่อกันด้วย \n เป็นใบเสร็จหนึ่งใบ
string Naive() { string acc = ""; foreach (var l in lines) acc += l + "\n"; return acc; }
string Sb() { var sb = new StringBuilder(); foreach (var l in lines) sb.Append(l).Append('\n'); return sb.ToString(); }
string SbCap() { var sb = new StringBuilder(256); foreach (var l in lines) sb.Append(l).Append('\n'); return sb.ToString(); }
string Join() => string.Join('\n', lines);
// ไม่สร้าง string เลย: เขียน UTF-8 ต่อท้ายลง buffer ที่ใช้ซ้ำint IntoBuffer(byte[] scratch){ int p = 0; foreach (var l in lines) { System.Text.Unicode.Utf8.FromUtf16(l, scratch.AsSpan(p), out _, out int w); p += w; scratch[p++] = (byte)'\n'; } return p;}| input ที่วัด | Allocated |
|---|---|
acc += l + "\n" ใน loop หกรอบ | 1168 B |
new StringBuilder() แล้ว Append หกรอบ | 1200 B |
new StringBuilder(256) แล้ว Append หกรอบ | 912 B |
| StringBuilder ตัวเดิมที่ reuse (Clear แล้ว ToString) | 328 B |
string.Join('\n', lines) | 328 B |
string.Create แบบ one-pass | 328 B |
string.Concat(lines) — ไม่มีตัวคั่น | 312 B |
เขียน UTF-8 ลง byte[] ที่ reuse | 0 B |
new StringBuilder() แพงกว่า += ที่มันควรจะมาแทน และการถอดรหัสอธิบายได้ตรงไปตรงมา new StringBuilder() เปล่าๆ ยังไม่ Append อะไรเลยก็เสีย 104 B แล้ว ส่วน new StringBuilder(256) เปล่าๆ เสีย 584 B เพราะมันจอง buffer ภายในขนาดนั้นทันที
ฝั่ง += โตทีละบรรทัดแบบนี้ 72 / 192 / 360 / 568 / 840 / 1168 B — แต่ละรอบสร้าง string ใหม่ทั้งก้อนแล้วทิ้งของเดิม ซึ่งเป็นพฤติกรรมที่ตำราเตือนไว้ถูกแล้ว ตำราแค่แนะนำตัวแทนผิดตัวสำหรับงานขนาดนี้
string.Join ที่เขียนสั้นที่สุดในตาราง ได้ 328 B เท่ากับ StringBuilder ที่ reuse และเท่ากับ string.Create แบบ one-pass ที่เขียนยากที่สุด
สิ่งที่วัดคือใบเสร็จ หกบรรทัดสั้นๆ เท่านั้น และในงานขนาดนี้ต้นทุนคงที่ของการสร้าง StringBuilder ใหม่กินกำไรทั้งหมด
แถวที่ต้องอ่านคู่กันคือ StringBuilder ที่ reuse ซึ่งได้ 328 B เท่ากับ string.Join พอดี — พฤติกรรมของเครื่องมือไม่ได้แย่ สิ่งที่แย่คือการสร้างมันใหม่ทุกครั้ง
งานที่ยาวกว่านี้มาก เช่น ต่อ string หลายพันบรรทัด บทนี้ยังไม่ได้วัด จึงไม่มีสิทธิ์สรุปแทน
เงินและ UTF-8: จุดที่กำไรหลุดมือ
หัวข้อที่มีชื่อว่า “เงินและ UTF-8: จุดที่กำไรหลุดมือ”สองงานที่เจอทุกใบเสร็จ คือจัดรูปจำนวนเงินกับเขียน byte ออกไป และจุดเดียวที่ทำให้กำไรทั้งหมดคืนกลับไป
input คือ satang = 18000 ที่ต้องกลายเป็น 180.00 บาท
| input ที่วัด | Allocated |
|---|---|
(satang / 100m).ToString("0.00") | 40 B |
(satang / 100.0).ToString("0.00") | 40 B |
$"{satang / 100m:0.00} บาท" (ผลลัพธ์ 10 อักขระ) | 48 B |
(satang / 100m).ToString("0.00") + " บาท" | 88 B |
(satang / 100m).TryFormat(buf, out w, "0.00") ลง stackalloc char[32] | 0 B |
int math ล้วน: (satang / 100) และ (satang % 100) TryFormat ลง stackalloc char[32] | 0 B |
(satang / 100m).TryFormat(u8, out w, "0.00") ลง stackalloc byte[32] | 0 B |
แถวที่สามน่าสนใจที่สุด 48 B คือขนาดของ string 10 อักขระพอดีตามตาราง M6.11 แปลว่าช่อง decimal ใน interpolation ไม่ได้จ่ายค่า boxing เพิ่ม เลยที่ tier-1 ส่วนแถวที่สี่ที่แยก ToString ออกมาแล้วต่อ string เสีย 88 B เพราะจ่ายทั้ง string กลางทางและ string ผลลัพธ์
ฝั่ง UTF-8 กับดักอยู่ที่จุดเดียวและมันสั้นมาก
| input ที่วัด | Allocated |
|---|---|
"1042|"u8 | 0 B |
Encoding.UTF8.GetBytes("1042|") | 32 B |
"1042|"u8.ToArray() | 32 B |
"ต้มยำกุ้ง"u8 | 0 B |
Encoding.UTF8.GetBytes("ต้มยำกุ้ง") | 56 B |
payload.AsSpan().StartsWith("1042"u8) | 0 B |
Encoding.UTF8.GetString(payload).StartsWith("1042") | 328 B |
Utf8.FromUtf16(runtimeDish, stackalloc byte[128], …) | 0 B |
"…"u8 ให้ 0 B เพราะมันเป็นข้อมูลที่ฝังอยู่ในไบนารีแล้ว ไม่ได้สร้างตอนรัน — แล้วต่อท้ายด้วย .ToArray() เมื่อไร กำไรคืนไปทั้งหมดทันที 32 B เท่ากับ Encoding.UTF8.GetBytes พอดี
ข้อจำกัดที่ต้องรู้คือ u8 ใช้ได้กับ literal เท่านั้น ชื่อเมนูที่มาจาก request ตอนรันใช้ไม่ได้ ทางของมันคือ transcode ลง buffer บน stack ด้วย Utf8.FromUtf16 ซึ่งวัดได้ 0 B
ข้อเท็จจริงเรื่องความยาวที่ต้องเผื่อ buffer ให้ถูก "1042|"u8.Length = 5 ส่วน "ต้มยำกุ้ง"u8.Length = 27 byte เทียบกับ 9 อักขระ UTF-16 — ภาษาไทยกิน3 byte ต่ออักขระใน UTF-8 buffer ที่ตั้งขนาดจาก .Length ของ string จึงเล็กเกินไปเสมอ
ค้น dictionary โดยไม่สร้าง key และเงื่อนไขจริงของ GetAlternateLookup
หัวข้อที่มีชื่อว่า “ค้น dictionary โดยไม่สร้าง key และเงื่อนไขจริงของ GetAlternateLookup”ทางเดียวในบทนี้ที่ลดจากตัวเลขจริงลงถึง 0 B และ folklore เรื่องเงื่อนไขของมันที่วัดแล้วไม่จริง
GetAlternateLookupGetAlternateLookupทางค้น dictionary ด้วย `ReadOnlySpan<char>` โดยไม่ต้องสร้าง string key ขึ้นมาก่อน ใช้ได้เมื่อ comparer รองรับ คือทางค้น dictionary ด้วย ReadOnlySpan<char> โดยไม่ต้องสร้าง string key ขึ้นมาก่อน ซึ่งเป็นตัวที่พาตาราง M6.1 ข้างบนจาก 280 B ลงไป 0 B
folklore ที่ได้ยินบ่อยคือ “ใช้ได้เฉพาะ StringComparer.Ordinal” วัดแล้วไม่จริง
| input ที่วัด (comparer ของ dictionary) | TryGetAlternateLookup |
|---|---|
comparer ปริยาย และ EqualityComparer<string>.Default | true |
StringComparer.Ordinal และ OrdinalIgnoreCase | true |
StringComparer.InvariantCulture และ InvariantCultureIgnoreCase | true |
StringComparer.CurrentCulture | true |
StringComparer.Create(new CultureInfo("th-TH"), false) | true |
custom IEqualityComparer<string> ที่ไม่ implement IAlternateEqualityComparer | false |
เงื่อนไขจริงคือ comparer ต้อง implement IAlternateEqualityComparer ไม่ใช่ว่าต้องเป็น Ordinal — comparer มาตรฐานทุกตัวรวม culture-aware และ th-TH ผ่านหมด ตัวที่พังคือ comparer ที่เขียนเอง ซึ่ง GetAlternateLookup จะโยน InvalidOperationException ข้อความว่า “The collection’s comparer does not support the requested operation.”
ของแถมที่วัดไปด้วย รองรับบน HashSet<string> FrozenDictionary<string,int> และ ConcurrentDictionary<string,int> เหมือนกัน ส่วน TryGetAlternateLookup<ReadOnlySpan<byte>> บน Dictionary<string,int> ได้ false เพราะไม่มี comparer ตัวไหนแปลง byte span ให้เป็น string ได้
พฤติกรรมสองข้อที่ต้องรู้ก่อนใช้จริง lookup.TryAdd(span) materialise key หนึ่งครั้ง ตอนเพิ่มค่าใหม่ (หลังจากนั้น key ใน dictionary เป็น String ตามปกติ) และการ TryAdd key ที่มีอยู่แล้ววัดได้ 0 B ส่วน struct AlternateLookup ที่ cache ไว้ใน field ยังเห็นการเขียนที่เกิดผ่าน dictionary ตัวแม่ในภายหลัง จึงไม่ต้องขอใหม่ทุกรอบ
แยก field ทั้งบรรทัดโดยไม่ materialise อะไรเลย
หัวข้อที่มีชื่อว่า “แยก field ทั้งบรรทัดโดยไม่ materialise อะไรเลย”หลักการเดียวกันใช้ได้กับการแยก field ไม่ใช่แค่การค้น key
string[] parts = line.Split('|'); // 184 Bint v1 = int.Parse(parts[2]) * int.Parse(parts[3]); // ยังเป็น 184 B เท่าเดิม การ parse เองไม่เสียอะไร
Span<Range> r = stackalloc Range[8];ReadOnlySpan<char> s = line.AsSpan();int n = s.Split(r, '|'); // 0 Bint v2 = int.Parse(s[r[2]]) * int.Parse(s[r[3]]); // 0 B| input ที่วัด | Allocated |
|---|---|
line.Split('|') หนึ่งบรรทัด | 184 B |
↳ new string[4] | 56 B |
↳ field ทั้งสี่ "1042" "ต้มยำกุ้ง" "2" "18000" | 32 + 40 + 24 + 32 B |
MemoryExtensions.Split ลง stackalloc Range[8] | 0 B |
เดิน IndexOf ด้วยมือ | 0 B |
int.Parse(ReadOnlySpan<char>) | 0 B |
line.Split('|') ทั้งหกบรรทัด | 1144 B |
field สี่ก้อนของบรรทัดนั้นถอดรหัสตรงกับตาราง M6.11 ทุกก้อน "1042" สี่อักขระได้ 32 B "ต้มยำกุ้ง" เก้าอักขระได้ 40 B "2" หนึ่งอักขระได้ 24 B และ "18000" ห้าอักขระได้ 32 B บวก array อีก 56 B ได้ 184 B พอดี
การ parse ตัวเลขเองไม่ได้เสียอะไรเลยทั้งสองทาง สิ่งที่เสียคือ การสร้างชิ้นส่วนขึ้นมาให้ parse เท่านั้น
SearchValues: allocation เท่ากันทั้งคู่ กำไรอยู่ที่ throughput ของเซ็ตใหญ่
หัวข้อที่มีชื่อว่า “SearchValues: allocation เท่ากันทั้งคู่ กำไรอยู่ที่ throughput ของเซ็ตใหญ่”เทคนิคที่ตัดสินด้วย column Allocated แล้วไม่มีอะไรให้ตัดสิน จึงเป็นที่เดียวในบทนี้ที่ต้องยกตัวเลขเวลามาพูด พร้อมเงื่อนไขครบชุด
SearchValuesSearchValuesชุดค่าที่เตรียมโครงสร้างค้นหาไว้ล่วงหน้าครั้งเดียว กำไรอยู่ที่ throughput ของเซ็ตใหญ่ ไม่ใช่ที่ allocation คือชุดค่าที่เตรียมโครงสร้างค้นหาไว้ล่วงหน้าครั้งเดียว แล้วเอาไปใช้กับ IndexOfAny ซ้ำๆ
static readonly char[] DelimArray3 = ['|', ';', ','];static readonly SearchValues<char> Delim3 = SearchValues.Create("|;,");
const string BigSet = "|;,\t\r\n\"'\\<>{}[]()&%$#"; // 20 ค่าstatic readonly char[] BigArray = BigSet.ToCharArray();static readonly SearchValues<char> BigSv = SearchValues.Create(BigSet);| input ที่วัด | Allocated |
|---|---|
char[] ที่เป็น static readonly | 0 B |
SearchValues<char> ที่เป็น static readonly | 0 B |
SearchValues.Create(…) ที่เรียกใน loop | 24 B ต่อครั้ง |
column ที่คอร์สนี้ใช้ตัดสิน อ่านได้ 0 B เท่ากันทั้งคู่ ตามเกณฑ์ของคอร์สนี้ SearchValues จึงไม่ใช่เทคนิคลด allocation และกับดักเดียวของมันคือการเผลอเรียก Create ใน loop ซึ่งเสีย 24 B ทุกครั้ง
กำไรของมันอยู่ที่ throughput และวัดได้เฉพาะเมื่อเซ็ตใหญ่พอ ตัวเลขข้างล่างวัดบน payload 200 บรรทัดออเดอร์ บนเครื่อง Intel Core i7-7700 3.60GHz (Kaby Lake) · 8 logical / 4 physical · Ubuntu 24.04.4 LTS (WSL2) · linux-x64 · .NET 10.0.10 RyuJIT x86-64-v3 · BenchmarkDotNet 0.15.8 DefaultJob
| รัน | input ที่วัด (payload 200 บรรทัดออเดอร์ทุกแถว) | char[] | SearchValues<char> |
|---|---|---|---|
| ที่ 1 | เซ็ต 3 ค่า "|;," | 5.953 us (Error 0.1068, StdDev 0.2580) | 5.766 us (Error 0.1150, StdDev 0.2452) |
| ที่ 1 | เซ็ต 20 ค่า BigSet ข้างบน | 33.759 us (Error 1.1767, StdDev 3.4510) | 11.154 us (Error 1.4839, StdDev 4.3051) |
| ที่ 2 | เซ็ต 3 ค่า "|;," | 7.892 us (Error 0.4854, StdDev 1.4004) | 8.883 us (Error 0.7846, StdDev 2.2513) |
| ที่ 2 | เซ็ต 20 ค่า BigSet ข้างบน | 54.130 us (Error 4.1659, StdDev 11.7501) | 10.227 us (Error 0.3330, StdDev 0.9819) |
ระดับสัมบูรณ์ของสองรันนี้ไม่ใช่ข้อกล่าวอ้าง เครื่องถูกใช้ร่วมกับ process พี่น้องระหว่างวัด ค่ากลางของ benchmark ชุดเดียวกันจึงขยับได้ถึง 1.6 เท่าระหว่างรัน สิ่งที่อ้างได้มีสองข้อเท่านั้น
- เซ็ต 3 ค่า วัดไม่ต่างอย่างมีความหมาย error bar ของสองฝั่งทับกันทั้งสองรัน และรันที่ 2 ฝั่ง
SearchValuesยังช้ากว่าด้วยซ้ำ - เซ็ต 20 ค่า ต่างกันในระดับเท่าตัว ราว 3 เท่าในรันที่ 1 และราว 5 เท่าในรันที่ 2 ซึ่งใหญ่กว่าการกระจายของทั้งสองรันมาก
ข้อสรุปที่ใช้ได้จริงคือ อย่าเปลี่ยนไปใช้ SearchValues ทุกที่ที่มี IndexOfAny — มันจ่ายเมื่อเซ็ตใหญ่ และเมื่อ payload ยาวพอที่จะให้กำไรต่อรอบสะสมได้
ขีดจำกัดที่ต้องเขียนตรงๆ
หัวข้อที่มีชื่อว่า “ขีดจำกัดที่ต้องเขียนตรงๆ”สิ่งที่ข้อมูลชุดนี้ยังพิสูจน์ไม่ได้ และจะไม่ถูกอ้างที่ไหนอีกในคอร์สนี้
- 272 B ไม่ใช่ตัวเลขของบทนี้ ชุดชื่อเมนูหกรายการของบทนี้วัดได้ 280 B รูปแบบยืนยันได้ แต่ตัวเลขเป็น function ของความยาวชื่อ ทุกตัวเลขในบทนี้จึงต้องอ่านคู่กับ input ที่ผลิตมันเสมอ
- เส้นแบ่ง 300 ไม่ใช่สมบัติของ .NET ทุก runtime วัดบน CoreCLR linux-x64 .NET 10.0.10 เท่านั้น ส่วนค่าของ Mono ที่เป็น 10 บทนี้ อ่านจากซอร์ส ไม่ได้วัดเอง
- ตัวเลขเวลาในบทนี้แม่นแค่ระดับอันดับของขนาด ค่ากลางของ benchmark ชุดเดียวกันสองรอบต่างกันได้ถึง 1.6 เท่า อ้างได้แค่ “ราว 3 ถึง 5 เท่า” พร้อม StdDev เสมอ ห้ามอ้างเป็นเปอร์เซ็นต์
SearchValuesไม่ได้เร็วกว่าเสมอ และไม่ได้ช้ากว่าเสมอสำหรับเซ็ตเล็ก ที่วัดได้คือ “ไม่ต่างอย่างวัดได้” บน payload 200 บรรทัดกับเซ็ต 3 ค่าUtf8.TryWriteและ interpolation ไม่ได้ปลอด boxing ในทุกโหมด code ที่ยังไม่ถึง tier-1 มี boxing จริง 72 B และ$"{bool}"ยังต่างกันระหว่างโหมด tiered ที่ 32 B กับTieredCompilation=0ที่ 56 B- StringBuilder ไม่ได้แย่เสมอ ที่วัดคือใบเสร็จหกบรรทัดสั้นๆ ตัวที่ reuse ได้ 328 B เท่ากับ
string.Joinและงานที่ยาวกว่านี้มากบทนี้ยังไม่ได้วัด - บทนี้ไม่ได้อ่าน IL หรือ assembly ที่ JIT ปล่อยออกมา กลไกภายในที่ไม่ได้พิสูจน์จึงไม่ถูกอ้าง และบทนี้ไม่รับปากเรื่อง GC pause หรือ throughput ของ app จริง มันนับ byte ที่จัดสรรอย่างเดียว
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”รายการหลักฐานของบทนี้ และเทคนิคที่ตำราเชียร์แต่วัดแล้วไม่ช่วย
สิ่งที่บทนี้วัดไว้แล้ว และเอาไปใช้ต่อได้โดยไม่ต้องพิสูจน์ซ้ำ
- ค้น dictionary ด้วย
GetAlternateLookupแทนการสร้าง string key ลดจาก 280 B เหลือ 0 B บนหกบรรทัดออเดอร์ชุดที่พิมพ์ไว้ - แยก field ด้วย
MemoryExtensions.Splitลงstackalloc Range[8]แทนline.Split('|')ลดจาก 184 B ต่อบรรทัดเหลือ 0 B - เขียนลง buffer ของผู้เรียกด้วย
TryFormatแทนการคืนstringลดจากพื้น 72 B เหลือ 0 B - ใช้
"…"u8กับ literal และUtf8.FromUtf16กับ string ตอนรัน ลดจาก 32–56 B เหลือ 0 B ตราบใดที่ไม่เผลอต่อท้ายด้วย.ToArray()
เทคนิคที่ตำราเชียร์ แต่วัดบนเครื่องนี้แล้ว ไม่ช่วย
string.Createเขียนเองแทน interpolation — 72 B เท่ากันเป๊ะnew StringBuilder()แทน+=ใน loop หกรอบ — 1200 B แพงกว่า 1168 BSearchValues<char>แทนchar[]ที่ cache ไว้ — 0 B เท่ากันทั้งคู่ และเซ็ต 3 ค่าวัดเวลาไม่ต่าง
บท 7 ย้ายจาก string ไปที่เส้นทาง async และเปิดด้วยคำถามที่ทรงเดียวกับบทนี้เป๊ะ คือ ValueTask เป็น “Task รุ่นประหยัด” จริงไหม คำตอบที่วัดได้ไม่ได้เข้าข้างชื่อเล่นของมันเลย
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Dictionary<TKey,TValue>.GetAlternateLookup<TAlternateKey> — Microsoft Learn (2025-07-01, อัปเดต 2026-07-01; net-9.0/10.0/11.0) — API ที่ค้น dictionary ด้วย span ได้โดยไม่สร้าง key และเงื่อนไขว่า comparer ต้องรองรับ
- IAlternateEqualityComparer<TAlternate,T> — Microsoft Learn (2025-07-01, อัปเดต 2026-05-27) — interface ที่เป็นเงื่อนไขจริงของ alternate lookup แทน folklore เรื่อง Ordinal
- SearchValues<T> Class (System.Buffers) — Microsoft Learn (2025-07-01, อัปเดต 2026-05-27; net-8.0+) — ชุดค่าที่เตรียมโครงสร้างค้นหาไว้ล่วงหน้า สำหรับใช้กับ
IndexOfAny - dotnet/runtime — Number.Formatting.cs (สาขา release/10.0) (ดึงข้อมูล 2026-08-07) —
SmallNumberCacheLength = 300บน CoreCLR และ 10 บน Mono ซึ่งเป็นที่มาของเส้นแบ่งในบทนี้ - Built-in reference types (C# reference) — UTF-8 string literals (2026-01-14, อัปเดต 2026-05-19) — ข้อกำหนดของ
"…"u8ที่ใช้ได้กับ literal เท่านั้น
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3เนื้อความบทนี้บอกว่าการใช้ Substring เป็น key เสีย 280 B ต่อหกบรรทัดออเดอร์ ขณะที่ภาพประกอบหน้าเดียวกันขึ้นเลข 272 B ทั้งที่เป็นเทคนิคเดียวกัน ควรอ่านสองเลขนี้อย่างไร