بررسی نوع جنریک Complex در داتنت 11
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۶/۱۲ ۱۰:۰۵
آدرس: www.dntips.ir
چکیده: محاسبات علمی و پردازش سیگنال همواره به بازنمایی دقیق و بهینهٔ اعداد مختلط وابسته بودهاند. در پلتفرم داتنت، ساختار سنتیSystem.Numerics.Complexدههها با مؤلفههای مبتنی بر ممیز شناور با دقت مضاعف (doubleیا ۶۴ بیتی) پیادهسازی شده بود؛ انتخابی مطمئن برای حفظ پایداری عددی، اما ناکارآمد در سناریوهایی که فشار حافظه یا توان محاسباتی سختافزار نیازمند بازنماییهای سبکتر نظیرfloat(۳۲ بیتی) است. با معرفی و تکامل ریاضیات جنریک (Generic Math) در داتنت، معرفی ساختار جنریکComplexگامی مهم در جداسازی مفهوم ریاضی عدد مختلط از دقت پایهای سختافزاری آن بهشمار میرود. این مقاله به بررسی ساختار درونیComplex، تحلیل موازنهٔ میان مصرف حافظه و خطای انباشت محاسباتی (Precision vs. Memory)، بررسی تأثیر پهنای باند حافظه و برداریسازی سختافزاری (SIMD)، و در نهایت شیوهٔ ارزیابی تجربی این نوع داده با استفاده از کتابخانهٔ استانداردBenchmarkDotNetمیپردازد.
System.Numerics.Complex از سالها پیش برای مدیریت این نیاز طراحی شد؛ اما معماری داخلی آن بهصورت هاردکد (Hardcoded) روی دو فیلد از نوع double سوار شده بود:using System.Numerics; // پیادهسازی کلاسیک مبتنی بر double Complex value = new(3.0, 4.0); Complex result = value * 2.0;
Complex در این اکوسیستم، امکان تعریف اعداد مختلط با زیرساختهای عددی مختلف نظیر float، double یا سایر انواع ممیز شناور را فراهم ساخته است.Complexو نقش ریاضیات جنریکComplex<T> ├── Real: T └── Imaginary: T
IFloatingPoint یا INumber) و قابلیت اعضای استاتیک مجازی در اینترفیسها (Static Virtual Members in Interfaces) فراهم شده است. این زیرساخت به کامپایلر Roslyn و JIT این امکان را میدهد که بدون تحمیل هزینهٔ Boxing یا ارجاع غیرمستقیم، کدهای انتزاعی را به بایتکد و سپس به دستورات ماشین بومی و کاملاً بهینهسازیشده تبدیل کنند.Complex به 8 + 8 = 16 بایت حافظه نیاز دارد، در حالی که Complex دقیقاً نیمی از این مقدار (4 + 4 = 8 بایت) را مصرف میکند.// تعریف نوع با دقت معمولی (۳۲ بیتی) Complex<float> singlePrecision = new(1.5f, 2.5f); // تعریف نوع با دقت مضاعف (۶۴ بیتی) Complex<double> doublePrecision = new(1.5, 2.5);
| نوع داده | حجم مؤلفه | بایت بر هر عدد مختلط | حجم فرضی ۱۰ میلیون رکورد |
Complex | ۴ بایت | ۸ بایت | ~ ۸۰ مگابایت |
Complex | ۸ بایت | ۱۶ بایت | ~ ۱۶۰ مگابایت |
Complex جا میگیرد، در حالی که فقط ۴ عنصر Complex میتوانند همزمان در یک خط کش بارگذاری شوند.float و double اساساً یک تصمیم مهندسی و علمی مبتنی بر موازنه (Trade-off) است:float تنها حدود ۷ رقم بامعنی (Significant Digits) دقت دارد، در حالی که double دقتی در حدود ۱۵ تا ۱۷ رقم ارائه میدهد. در الگوریتمهای تکرارشونده (Iterative) یا فیلترهای پسخوراند (IIR Filters)، خطاهای ممیز شناور میتوانند بهسرعت تشدید شوند و الگوریتم را از همگرایی خارج کنند.float تقریباً از 10^-45 تا 10^38 است، در حالی که در double این دامنه تا 10^308 گسترش مییابد. در محاسبات ماتریسی یا بهینهسازیهای عددی بزرگ، احتمال بروز مقادیر بینهایت (Infinity) یا صفرشدن غیرواقعی مقادیر بسیار کوچک (Denormal/Subnormal Numbers) در float بهمراتب بیشتر است.float (معادل ۴ عدد مختلط تکدقت) را در یک پالس ساعت ضرب یا جمع کند.double، همان ثبات تنها ظرفیت پردازش ۴ مقدار double (معادل ۲ عدد مختلط مضاعف) را داراست.Complex بهصورت تئوریک پتانسیل دستیابی به تا دو برابر توان محاسباتی را فراهم میسازد؛ مشروط بر آنکه ساختار محاسبات محدود به حافظه (Memory-Bound) نباشد و خط لولههای SIMD مسدود نشوند.using System.Numerics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
[DisassemblyDiagnoser(printSource: true, maxDepth: 3)] // بررسی خروجی JIT و SIMD
public class ComplexBenchmark
{
private const int Iterations = 10_000;
private readonly Complex<float> _floatVal = new(1.0001f, 0.0001f);
private readonly Complex<double> _doubleVal = new(1.0001, 0.0001);
[Benchmark(Baseline = true)]
public Complex<double> DoubleScalarLoop()
{
var acc = _doubleVal;
for (int i = 0; i < Iterations; i++)
{
acc *= _doubleVal;
}
return acc;
}
[Benchmark]
public Complex<float> FloatScalarLoop()
{
var acc = _floatVal;
for (int i = 0; i < Iterations; i++)
{
acc *= _floatVal;
}
return acc;
}
}[MemoryDiagnoser]
public class ComplexArrayWorkloadBenchmark
{
private const int Size = 1_000_000;
private Complex<float>[] _floatArray = null!;
private Complex<double>[] _doubleArray = null!;
[GlobalSetup]
public void Setup()
{
_floatArray = new Complex<float>[Size];
_doubleArray = new Complex<double>[Size];
for (int i = 0; i < Size; i++)
{
_floatArray[i] = new Complex<float>(1.01f, 0.99f);
_doubleArray[i] = new Complex<double>(1.01, 0.99);
}
}
[Benchmark]
public void TransformFloatArray()
{
var factor = new Complex<float>(0.5f, 0.5f);
for (int i = 0; i < _floatArray.Length; i++)
{
_floatArray[i] *= factor;
}
}
[Benchmark]
public void TransformDoubleArray()
{
var factor = new Complex<double>(0.5, 0.5);
for (int i = 0; i < _doubleArray.Length; i++)
{
_doubleArray[i] *= factor;
}
}
}نکته اجرایی: همواره آزمونها را در حالت انتشار کامپایل کنید:
dotnet run -c Release
Complex با یک پیادهسازی مرجع مبتنی بر double، مقایسه نباید برابری دقیق (==) را هدف قرار دهد، بلکه باید با تعریف یک آستانه خطای مجاز (Tolerance) اعتبارسنجی شود:public static class NumericalAssertion
{
public static bool AreApproximatelyEqual(
Complex<float> actual,
Complex<double> expectedReference,
float tolerance = 1e-4f)
{
float realDiff = MathF.Abs(actual.Real - (float)expectedReference.Real);
float imagDiff = MathF.Abs(actual.Imaginary - (float)expectedReference.Imaginary);
return realDiff <= tolerance && imagDiff <= tolerance;
}
}tolerance به ماهیت الگوریتم بستگی دارد. به عنوان مثال، در مسائل خوشحالت (Well-conditioned problems) آستانه خطای کوچکتری انتظار میرود؛ اما در تبدیلهای پیچیده مثل تبدیل فوریه معکوس گسسته با ابعاد بالا، خطای انباشت ممکن است آستانهٔ بزرگتری را تحمیل کند.float به این معنا نیست که محاسبات پردازنده لزوماً دو برابر سریعتر اجرا میشود. در مسائل CPU-bound بدون برداریسازی کارآمد، واحدهای ممیز شناور (FPU) عملیات تک و مضاعف را معمولاً با Latency مشابهی به پایان میرسانند.Complex به توسعهدهندگان پلتفرم داتنت این اختیار را میدهد که سیستمهای خود را فراتر از قیود از پیشتعیینشده بهینهسازی کنند.Complex بهدلیل کاهش جابجایی داده (Data Movement) میتواند بهبودهای جدی ایجاد کند.