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

งาน 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 ชุด​เดียวกัน

📦 kaen-food-ordering

ทั้ง​บท​เดิน​บน 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-16Allocated
"ต้มยำกุ้ง"940 B
"ผัดไทยกุ้งสด"1248 B
"ข้าวผัดปู"940 B
"ส้มตำไทย"840 B
"แกงเขียวหวานไก่"1556 B
"มะม่วงข้าวเหนียว"1656 B
รวม280 B

ทีนี้​เลื่อน​ลง​ไป​ดู​ภาพประกอบ​เชิง​โต้ตอบ​ข้าง​ล่าง มัน​แสดง​ฉาก​เดียวกันเป๊ะ คือ Substring key เทียบ​กับ alternate lookup แต่​มัน​ขึ้น​เลข 272 B ไม่ใช่ 280 B

⚠️ 280 B กับ 272 B ไม่​ได้​หักล้าง​กัน — มัน​คือ input คนละ​ชุด

สอง​ตัวเลข​นี้​มา​จาก 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 อย่างไร

6 / 6 ออเดอร์

แกนของกล่องนี้คือ จำนวนออเดอร์ที่ประมวลผลไปแล้ว ไม่ใช่เวลา — จังหวะของปุ่ม “เดินอัตโนมัติ” เป็นค่าคงที่ที่ตั้งไว้เพื่อให้ดูทัน ไม่ได้แทนความเร็วจริงของ 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)

byte ที่จองต่อออเดอร์ ของ Substring เทียบกับ GetAlternateLookup
ออเดอร์SubstringGetAlternateLookupก้อนที่เกิดขึ้น
ผัดไทยกุ้งสด480▨ "ผัดไทยกุ้งสด" 48 B
ต้มยำกุ้ง400▨ "ต้มยำกุ้ง" 40 B
ข้าวมันไก่480▨ "ข้าวมันไก่" 48 B
ส้มตำไทย400▨ "ส้มตำไทย" 40 B
แกงเขียวหวานไก่560▨ "แกงเขียวหวานไก่" 56 B
ชาไทยเย็น400▨ "ชาไทยเย็น" 40 B
รวมหกออเดอร์2720ผลลัพธ์ที่คำนวณได้เท่ากันทั้งสองเลน (87,500)

วนซ้ำหลายรอบแล้ววัดใหม่ทั้งหมด — ไม่ได้เอาเลขข้างบนไปคูณ

จำนวนรอบ1101001,000
Substring2722,72027,200272,000
GetAlternateLookup0000
ประมวลผลแล้ว 6 จาก 6 ออเดอร์ · Substring จอง 272 byte · GetAlternateLookup จอง 0 byte
ทุกตัวเลขในกล่องนี้คือผลของ 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 ถึง 532 B
ความ​ยาว 6 ถึง 940 B
ความ​ยาว 1048 B
ความ​ยาว 2272 B

ทั้ง 11 จุด​ที่​วัด​เข้า​กับ​สูตร 24 + 8*ceil((len-1)/4) ผล​คือ​ความ​ยาว​ที่​ต่าง​กัน​หนึ่ง​อักขระ​อาจ​ไม่​ทำให้ byte เปลี่ยน​เลย แต่​พอ​ข้าม​ขั้น​ก็​กระโดด​ที​ละ 8 B

นี่​คือ​เหตุผล​ที่​เลข​สาม​หลัก​อย่าง "300" เสีย 32 B และ​บรรทัด​ออร์เดอร์ 22 อักขระ​เสีย 72 B และ​เป็น​เหตุผล​ที่​ชุด​ชื่อ​เมนู​สอง​ชุด​ข้าง​บน​ให้​ผล​รวม​ต่าง​กัน 8 B ทั้ง​ที่​มี​หก​ก้อน​เท่า​กัน

ตัวเลข​ทั้ง​บท​ผูก​กับ x64 (IntPtr = 8) บน​สถาปัตยกรรม​อื่น​ตาราง​นี้​เปลี่ยน​ทั้ง​ตาราง

method ชุด​เดียวกัน​ใน​ไบนารี​เดียวกัน รายงาน 72 B ตอน​ต้น process และ 0 B หลัง​ถูก​เลื่อน​ขั้น พร้อม​ตัวเลข​ทั้ง​คู่​ตาม​ที่​คอร์ส​นี้​บังคับ​ไว้

ก่อน​จะ​เชื่อ​ตัวเลข​ไหน​ใน​บท​นี้ ต้อง​รู้​ก่อน​ว่า​เครื่อง​วัด​ที่​อุ่น​ไม่​พอ ไม่​ได้ รายงาน​ว่า​วัด​ไม่​ได้ มัน​รายงาน​ตัวเลข​ที่​นิ่ง ซ้ำ​ได้ และ​ผิด

// ไบนารีเดียวกัน วัดสองแบบ
// แบบที่ 1: อุ่นแค่ 2000 รอบติดกันแล้ววัดทันที -> ได้เลข tier-0
// แบบที่ 2: อุ่นจนพ้น call-counting delay จนโปรบสามครั้งติดกันเท่ากัน -> ได้เลข tier-1
Span<byte> buf = stackalloc byte[256];
System.Text.Unicode.Utf8.TryWrite(buf, $"{id}|{dish}|{qty}|{satang}", out int written);
input ที่​วัดtier-0tier-1
Utf8.TryWrite บรรทัด​ออร์เดอร์​เต็ม 1042 ต้มยำกุ้ง 2 1800072 B0 B
$"{satang / 100m:0.00}" ส่วนเกิน​ของ​ช่อง decimal+32 B0 B
$"{DateTime:yyyy-MM-dd}" ส่วนเกิน​ของ​ช่อง DateTime+24 B0 B
new char[] { 'a', 'b', 'c' }104 B32 B
string.IndexOfAny(new char[] { … })104 B0 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 ซ้าย​เป็น​ของ​ปลอม

ตัวเลข​เดียวกัน​สอง​ค่าที่​ต่าง​กัน​แค่​หนึ่ง​หน่วย ให้ 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
ค่า​ถึง 9999932 B
100000 ถึง 100000040 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 ไม่​ได้​เปลี่ยน​สัก​ตัว​อักษร

⛔ เส้น​แบ่ง 300 เป็น implementation detail ห้าม​ออกแบบ 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 + "|" + satang216 B
new StringBuilder() แล้ว Append แล้ว ToString280 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 เพราะ​ต่ำ​กว่า 3000 B
(18000).ToString()32 B
new string[7] — params array80 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 ใน 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-pass328 B
string.Concat(lines) — ไม่มี​ตัว​คั่น312 B
เขียน UTF-8 ลง byte[] ที่ reuse0 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 ใหม่​กิน​กำไร​ทั้งหมด

แถว​ที่​ต้อง​อ่าน​คู่​กัน​คือ StringBuilder ที่ reuse ซึ่ง​ได้ 328 B เท่ากับ string.Join พอดี — พฤติกรรม​ของ​เครื่องมือ​ไม่​ได้​แย่ สิ่ง​ที่​แย่​คือ​การ​สร้าง​มัน​ใหม่​ทุก​ครั้ง

งาน​ที่​ยาว​กว่า​นี้​มาก เช่น ต่อ string หลาย​พัน​บรรทัด บท​นี้​ยัง​ไม่​ได้​วัด จึง​ไม่มี​สิทธิ์​สรุป​แทน

สอง​งาน​ที่​เจอ​ทุก​ใบเสร็จ คือ​จัด​รูป​จำนวน​เงิน​กับ​เขียน 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|"u80 B
Encoding.UTF8.GetBytes("1042|")32 B
"1042|"u8.ToArray()32 B
"ต้มยำกุ้ง"u80 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>.Defaulttrue
StringComparer.Ordinal และ OrdinalIgnoreCasetrue
StringComparer.InvariantCulture และ InvariantCultureIgnoreCasetrue
StringComparer.CurrentCulturetrue
StringComparer.Create(new CultureInfo("th-TH"), false)true
custom IEqualityComparer<string> ที่​ไม่ implement IAlternateEqualityComparerfalse

เงื่อนไข​จริง​คือ 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 ไม่ใช่​แค่​การ​ค้น key

string[] parts = line.Split('|'); // 184 B
int 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 B
int 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 readonly0 B
SearchValues<char> ที่​เป็น static readonly0 B
SearchValues.Create(…) ที่​เรียก​ใน loop24 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 B
  • SearchValues<char> แทน char[] ที่ cache ไว้ — 0 B เท่า​กัน​ทั้ง​คู่ และ​เซ็ต 3 ค่า​วัด​เวลา​ไม่​ต่าง

บท 7 ย้าย​จาก string ไป​ที่​เส้นทาง async และ​เปิด​ด้วย​คำถาม​ที่​ทรง​เดียว​กับ​บท​นี้​เป๊ะ คือ ValueTask เป็น “Task รุ่น​ประหยัด” จริง​ไหม คำ​ตอบ​ที่​วัด​ได้​ไม่​ได้​เข้า​ข้าง​ชื่อเล่น​ของ​มัน​เลย


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

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

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

ข้อ 1 / 3

เนื้อความบทนี้บอกว่าการใช้ Substring เป็น key เสีย 280 B ต่อหกบรรทัดออร์เดอร์ ขณะที่ภาพประกอบหน้าเดียวกันขึ้นเลข 272 B ทั้งที่เป็นเทคนิคเดียวกัน ควรอ่านสองเลขนี้อย่างไร