งาน 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 ทั้งที่เป็นเทคนิคเดียวกัน ควรอ่านสองเลขนี้อย่างไร