in Technology

ทดสอบความเร็ว .NET Core 3.0 และฟีเจอร์ ReadyToRun (R2R) ในการทำ AWS Lambda

เมื่อ 23 กันยายน ที่ผ่านมา Microsoft ได้ฤกษ์เปิดตัว .NET Core 3.0 (3.0.100) ตัวเต็มให้ได้เล่นกัน หลังจากเป็นข่าวมาร่วมปี ซึ่งมาพร้อมกับฟีเจอร์และพัฒนา Performance จากเวอร์ชัน 2.2 เยอะพอสมควร ใครอยากรู้รายละเอียดสามารถไปตามอ่านได้ที่ Announcing .NET Core 3.0

ในครั้งนี้มีฟีเจอร์หนึ่งที่น่าสนใจคือ ReadyToRun (R2R) ซึ่งจริงๆ มันมาตั้งแต่ .NET Core 3 Preview 6 แล้ว นั่นคือทำให้ .NET Core application แปลงเป็น Native App ให้ไวขึ้น ส่งผลกับ startup time ที่ดีขึ้น (AOT – ahead-of-time) จากแต่เดิมซึ่งมีเพียงแค่ แปลง Byte Code เป็น Executable Code (JIT – Just in Time)

ผมนี่ตาลุกวาวทันที ไม่รอช้าที่จะหยิบมันมาทดสอบเป็น AWS Lambda เพื่อดูปัญหา Cold Start ว่าจะดีขึ้นได้มากน้อยแค่ไหน

เริ่มต้นใช้ ReadyToRun (How to Use)

วิธีการใช้ ReadyToRun ไม่ยาก แค่ตั้ง Config ในไฟล์ csproj ได้เลย ตัวอย่างที่ผมใช้จะประมาณนี้

<PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <AWSProjectType>Lambda</AWSProjectType>
    <PublishReadyToRun>true</PublishReadyToRun>
    <PublishTrimmed>true</PublishTrimmed>
    <PublishSingleFile>true</PublishSingleFile>
</PropertyGroup>

Config R2R ที่ใช้บ่อย จะมี ดังนี้

  • PublishReadyToRun คือ บอกให้แอพเรา Publish เป็น ReadyToRun Image
  • PublishTrimmed คือ ตัด assemblies ที่ไม่ได้ใช้ออกไป ซึ่งจะช่วยลดขนาดไฟล์ได้ (โดยจะวิเคราะห์ IL จาก Library ที่ชื่อว่า IL linker สามารถไปดู Docs เพื่อกำหนดค่าต่างๆได้)
  • PublishSingleFile คือ บอกว่าจะ Publish โดยแพ็คมาให้เป็น Executable File ไฟล์เดียว (ดูข้อมูลเพิ่มเติมได้ที่ design)

และเวลา publish จะต้องระบุ RuntimeIdentifier ด้วย เช่น

dotnet publish -r win-x64 -c Release

ดู Runtime เพิ่มเติมได้ที่ .NET Core Runtime IDentifier (RID) catalog

วิธีการทดสอบ (Test Scenarios)

ผมเคยทดสอบ AWS Lambda เพื่อแก้ปัญหา Cold Start ด้วยหลายๆ วิธี (อ่านเพิ่มเติมได้จาก 9 วิธีจัดการกับ Cold Start ใน Serverless ให้ทำงานได้ไวที่สุด พร้อมตัวอย่าง) ครั้งนี้ผมจึงอ้างอิงจากโค้ดชุดเดิม โดยจะทดสอบ 3 Scenarios ดังนี้

  1. ชุดโค้ด .NET Core Dll file ปกติ ที่เปลี่ยน Version จาก AWS Standard Runtime .NET Core 2.1 มาเป็นทำ Custom Runtime ขึ้นมาเองด้วย .NET Core 3.0.100 ผ่านไลบรารี่ Amazon.Lambda.RuntimeSupport
  2. ชุดโค้ด .NET Core Native ที่ใช้ไลบรารี่ LambdaNative และ Microsoft.DotNet.ILCompiler แต่เปลี่ยน Version จากเดิม Custom Runtime .NET Core 2.2 มาเป็น Custom Runtime .NET Core 3.0.100
  3. ชุดโค้ด .NET Core Native ที่ใช้ไลบรารี่ LambdaNative อย่างเดียว แต่เปลี่ยน Version จากเดิม Custom Runtime .NET Core 2.2 มาเป็น Custom Runtime .NET Core 3.0.100 โดยจะ Publish เป็น ReadyToRun Image

Database ผมใช้ MySQL 5.5 ที่อยู่บน AWS RDS วงเดียวกันกับ AWS Lambda ที่ผมทดสอบ และจำนวนข้อมูลมีเพียง 3 Rows

ผลการทดสอบ (Benchmark Results)

Scenario 1 – Normal Compile with Custom Runtime .NET Core 2.2 กับ .NET Core 3.0.100 โดยใช้ ADO ในการเชื่อมต่อกับ MySQL

จะสังเกตว่า .NET Core 3.0 ภาพรวมแล้ว เวลาของ Cold Start จะไวกว่า 2.2 เมื่อใช้ Memory 256Mb และ 512Mb แต่เมื่อเรียกใช้งานซ้ำๆ .NET Core 3.0 ทำงานได้ไวกว่าทุกกรณี

คราวนี้ลองมาดูเทียบ .NET Core 3.0.100 ด้วยกัน แบบที่ใช้ ReadyToRun กับแบบไม่ใช้

ผลคือ เมื่อทำเป็น ReadyToRun จะมีความเร็วของ Cold Start มากกว่าเดิม 50% แบบชนะขาดลอย ส่วนการเรียกใช้งานแบบซ้ำๆ ตรงนี้ เลขที่ผมได้ ยังเหวี่ยงๆ แต่โดยรวมถือว่าดีกว่า

มาลองเทียบ .NET Core 3.0.100 แบบที่ใช้ ReadyToRun แต่ใช้ Library Connect MySQL ต่างๆ

ผลตามคาด คล้ายกับบล็อกเดิมที่ผมเคยทดสอบ คือ ADO เร็วที่สุด รองมาเป็น Dapper และช้าสุดคือ EFCore แต่ทั้งนี้ ผมจะทดสอบใหม่อีกครั้งเมื่อ EFCore 3.0 รองรับการใช้งาน MySQL (ซึ่งตอนที่ผมเขียนบล็อกนี้ รองรับ MySQL แค่ EFCore 2.2 อยู่นะครับ)

และให้สังเกต ว่าตรง memory 128 ผมมีคำว่า (max) อยู่ หมายถึงว่า .NET Core 3.0 กิน memory มากขึ้นจน Lambda ใช้เกิน Memory 128Mb

ขึ้นแสดงแบบนี้ หมายถึงใช้ Memory มากกว่าที่จัดสรรให้ ตรงนี้ผมไม่แน่ใจว่ากระทบเงินอย่างไร แต่ที่เห้นชัดคือ ส่งผลทำให้ process ช้าลงพอสมควร น่าจะเกิดจากการต้องใช้และรอคืน memory ออกมาทำงาน จนกว่าจะเสร็จ process ทั้งหมด

Scenario 2 – .NET Core 3.0.100 with LambdaNative and Microsoft.DotNet.ILCompiler โดยใช้ ADO ในการเชื่อมต่อกับ MySQL

ที่ผมเคยเขียนถึง .NET Core 2.2 และ LambdaNative ไว้ใน 9 วิธีจัดการกับ Cold Start ใน Serverless ให้ทำงานได้ไวที่สุด พร้อมตัวอย่าง เป็นวิธีการที่แก้ปัญหา Cold Start ได้ดีที่สุด แต่เมื่อทดสอบกับ .NET Core 3.0 ด้วยแล้วปรากฎว่า ผมลัพธ์ดีขึ้นกว่าแต่เดิม และความเร็วเสถียรพอกันทุกครั้งที่มีการเรียกซ้ำๆ เป็นที่น่าพอใจมาก

Scenario 3 – .NET Core 3.0.100 with LambdaNative and ReadyToRun โดยใช้ ADO ในการเชื่อมต่อกับ MySQL

ลองใช้ LambdaNative ร่วมกับ ReadyToRun ปรากฎว่าทำงานได้ช้ากว่า ใช้ไลบรารี่ Microsoft.DotNet.ILCompiler เสียอีก

การทำงานของ ReadyToRun จะมีการ Extract Bundle Files (ดูข้อมูลเพิ่มเติมได้ที่ bundler) ก่อนทำการ Execution ผมจึงลองทดสอบแบบทำเป็น Single File ที่เอา Bundle รวมกับโค้ด (PublishSingleFile เป็น true) และแบบ Multiple File ที่เอา Bundle แยกกับโค้ด (PublishSingleFile เป็น false) มาเทียบกัน ปรากฎว่า แบบ Single File ทำงานได้เร็วกว่า (น่าจะมาจาก ahead-of-time ใน .NET Core 3.0 นี้)

ถ้าลองเทียบทั้งหมดจาก Scenario 2 และ Scenario 3 พบว่า .NET Core 3 NativeLambda ที่ทำงานกับ Microsoft.DotNet.ILCompiler จะทำงานได้ไวที่สุด

คราวนี้ลองถ้าลองเปลี่ยนตัว Connect MySQL มาเป็น Dapper ดูบ้าง ผลลัพธ์จะต่างกันขนาดไหน

ในส่วนของ LambdaNative ที่ทำงานกับ Microsoft.DotNet.ILCompiler ปัจจุบันยังไม่รองรับ Dapper เช่นเดิม ดังนั้นจึงมีแต่ผลทดสอบของ LambdaNative ที่ทำงานกับ ReadyToRun ซึ่งผลลัพธ์ที่ได้ Cold Start จะช้ากว่า ADO ประมาณ 0.5-2 วินาที เลยทีเดียว

สรุป

จากผลลัพธ์ทั้งหมดที่ลองทดสอบ ฟันธงได้ว่า .NET Core 3.0 ทำงานได้ไวกว่าเวอร์ชันเก่า ในการนำมาทำ Serverless อย่าง AWS Lambda

แต่ด้วยความที่เป็นของใหม่ ผมเชื่อว่าคงมีอะไรปล่อยออกมาอีกเรื่อยๆ ดังนั้น ขอแนะนำชาว .NET Core ว่ามันคุ้มค่าแก่การ migrate ไปเป็น 3.0 ครับ (ข่าวว่า .NET Core 3.1 จะออกมาในช่วงพฤศจิกายนนี้)

บล็อกนี้ พอเห็น Release ปุ๊บรีบทดสอบและเขียนไว้ทันที ใหม่จนยังไม่มีใครเขียนถึงเลย หากผิดพลาดประการใด สามารถแนะนำ/ติชมได้นะครับ

Demo Source Code