هذا تفكيك قريب لهياكل البيانات والخوارزميات وتخطيطات الذاكرة التي تشغّل ember. يفترض إنك قريت الـ architecture writeup وترغب التفاصيل التقنية.
المشروع صغير بما يكفي لتتبعه من CLI إلى logits. main.rs يملك generation وlogits dump وprobe modes. عوائل النماذج تنفذ ForwardModel. عمليات compute تمر عبر Backend، و CpuBackend هو runtime الحالي.
| backend trait | واجهة compute محدودة بدلاً من نشر تفاصيل CPU داخل كل architecture. |
| row-major tensors | الصفوف متجاورة؛ embeddings وoutput rows slices مباشرة. |
| gguf mmap | ملف النموذج mapped، والـ tensors تنسخ أو تفك ضغطها إلى buffers صريحة. |
| flat kv cache | تخصيص واحد مع stride math ثابت: [layer][head][pos][head_dim]. |
| artifacts over claims | scripts تكتب summaries قابلة للقراءة آلياً قبل التفسير. |
الحسابات الأساسية عادية لكنها حساسة: softmax يحتاج fallback للصفوف masked بالكامل، وlayer norm/RMS norm يجب أن يرجعوا إلى block stream، وRoPE conventions إذا اختلفت تنتج نصاً يبدو ممكناً لكنه خطأ.
attention ينقسم إلى q/k/v projections، positional handling أو RoPE، causal mask، softmax، ثم weighted value accumulation. الـ cache يحول decode إلى prefill واحد وخطوة cached لكل token جديد.
أكثر الأخطاء فائدة لم تكن crashes. layout خاطئ في GGUF أنتج نصاً عشوائياً، وbug في KV-cache prefill جعل النموذج يرى آخر token فقط. أول output متماسك كان smoke evidence فقط، وليس golden validation.
CpuTensor هو هيكل البيانات الوحيد التي يلمسه كل مكون آخر. ثلاث حقول:
pub struct CpuTensor {
pub shape: Vec<usize>, // e.g. [12, 64, 768]
pub strides: Vec<usize>, // contiguous row-major strides
pub data: Vec<f32>, // flat f32 buffer
}
الـ strides تُحسب لكن لا تُستخدم أبداً للفهرسة. دالة compute_strides تبني مصفوفة strides قياسية row-major (مثال: [768, 1] للشكل [64, 768])، لكن كل وصول يمر عبر رياضيات الإزاحة المباشرة مثل r * cols + c. الـ strides موجودة فقط لأجل get(&[usize])، المفهرس متعدد الأبعاد المستخدم فقط في الاختبارات.
كل عملية تعمل allocation. لا views، لا in-place. add() يرجع CpuTensor جديد. softmax() يرجع CpuTensor جديد. matmul() يرجع CpuTensor جديد. المخصص يشوف تيار allocations بنفس الحجم أثناء decode (عدد الـ tokens ثابت)، فـ jemalloc أو allocator النظام يعيد استخدام نفس الـ slab. هذا ليس عرضياً، إنه السبب التي يجعل الـ hot path يفلت من allocations المتكررة بدون لا يظهر كعاصفة malloc في profiling.
#[must_use] على كل عملية بحتة. إذا كتبت x.softmax(); من دون ربط النتيجة، التخصيص يُسقط بصمت والنموذج يعمل على بيانات قديمة. الـ attribute يجعل هذا compile error. كشف bug نسيان إسناد مخرج layer norm راجع لـ x في حلقة كتلة transformer، bug لا ينتج panic ولا NaN، فقط نص متدهور تدريجياً.
الـ matmul يفوض لـ crate matrixmultiply من bluss، sgemm pure-rust من دون ربط blas. موقع الاستدعاء:
unsafe {
matrixmultiply::sgemm(
m, k, n, // dimensions
1.0, // alpha
a.as_ptr(), k as isize, 1, // A: m×k, col stride = k, row stride = 1
b.as_ptr(), n as isize, 1, // B: k×n, col stride = n, row stride = 1
0.0, // beta
c.as_mut_ptr(), n as isize, 1, // C: m×n, row-major
);
}
matrixmultiply هو scalar، فهذا هو عنق زجاجة throughput. الـ Backend trait هو نقطة الإدخال لـ SIMD: نفّذ SimdBackend بنفس الـ trait، بدّل نوع الـ backend، وكل matmul في النموذج يستخدم الـ kernel الجديد بدون أي تغيير في شفرة النموذج.
الـ Backend trait هو التجريد الذي يفصل شفرة النموذج عن hardware. كل عملية تلمس بيانات tensor تمر عبر مرجع &B:
pub trait Backend {
type Tensor: Clone + Send + Sync;
type Error: core::error::Error;
fn zeroes(&self, shape: &[usize]) -> Result<Self::Tensor, Self::Error>;
fn matmul(&self, a: &Self::Tensor, b: &Self::Tensor) -> Result<Self::Tensor, Self::Error>;
fn add(&self, a: &Self::Tensor, b: &Self::Tensor) -> Result<Self::Tensor, Self::Error>;
fn softmax(&self, x: &Self::Tensor) -> Result<Self::Tensor, Self::Error>;
fn gelu(&self, x: &Self::Tensor) -> Result<Self::Tensor, Self::Error>;
fn layer_norm(&self, x: &Self::Tensor, w: &Self::Tensor, b: &Self::Tensor, eps: f32)
-> Result<Self::Tensor, Self::Error>;
fn index_select(&self, t: &Self::Tensor, index: usize) -> Result<Self::Tensor, Self::Error>;
fn assign_row(&self, dst: &mut Self::Tensor, index: usize, src: &Self::Tensor);
fn slice_cols(&self, x: &Self::Tensor, start: usize, end: usize) -> Self::Tensor;
fn shape<'a>(&self, x: &'a Self::Tensor) -> &'a [usize];
fn data<'a>(&self, x: &'a Self::Tensor) -> &'a [f32];
fn load_from_cpu(&self, data: Vec<f32>, shape: &[usize])
-> Result<Self::Tensor, Self::Error>;
fn add_broadcast(&self, x: &Self::Tensor, bias: &Self::Tensor)
-> Result<Self::Tensor, Self::Error>;
}
CpuBackend هو struct بحجم صفري يفوض كل دالة لـ CpuTensor. CpuError يغلف TensorError لعدم تطابق الأشكال والفهرسة خارج الحدود. دوال الـ trait مصممة لتكون قابلة للتنفيذ على أي hardware: GPU، SIMD، أو accelerator. primitives الخمسة لعائلة llama (rms_norm، silu، elemul، apply_rotary_emb، matmul_q8_0) تجلس بجانب primitives gpt-2 على الـ trait، كل منها مع تطبيق CpuBackend.
ليش هذا الـ layout. cache.get(layer) يرجع شريحة متجاورة &[f32] من كل heads والمواضع لتلك الطبقة. حلقة الـ attention تفهرس فيها برياضيات strides ثابتة، لا pointer chasing، لا nested vecs، allocation واحد. لـ GPT-2 small عند 2048 سياق: 12 × 12 × 2048 × 64 × 4 × 2 = ~72 MB.
الـ cursor هو موقع الكتابة في بعد التسلسل. هو لا يتقدم لكل طبقة. كل طبقة تقرأ cache.cursor() لتعرف أين تخزن، لكن المؤشر يتقدم فقط بعد لا تخلص كل الطبقات في Gpt2::forward_with_cache:
for (layer, block) in self.blocks.iter().enumerate() {
x = block.forward_with_cache(backend, &x, cache, layer)?;
}
// cursor advances here, after all 12 layers have stored.
for _ in 0..seq_len {
cache.advance_cursor();
}
هذا التصميم هو التي سبب bug #5. أثناء prefill، حلقة attention نادت append لكل token prompt، وكل نداء وصل عند cursor = 0، كاتباً فوق ما قبله. فقط k/v لآخر token نجا. الإصلاح يمرر pos صريح لـ append، محسوب كـ cache.cursor() + token_index في forward pass الـ attention.
Vec<f32> منفصل محجوز مسبقاً لـ max_seq_len (2048). مُعاد استخدامه عبر كل رأس وكل token في خطوة decode:
pub fn qk_scratch_mut(&mut self) -> &mut Vec<f32> {
&mut self.qk_scratch
}
المستدعي يفعل:
let scratch = cache.qk_scratch_mut();
scratch.clear();
scratch.resize(total_seq_len, f32::NEG_INFINITY);
// fill scratch[pos] = dot(q, k[pos]) for pos in 0..total_seq_len
// softmax in-place on scratch
// weight sum of v[pos] by scratch[pos]
لأن scratch خُصص لـ max_seq_len، الـ resize لا يعيد التخصيص أبداً طاعندما total_seq_len ≤ max_seq_len. هذا يزيل 144 heap allocation صغير لكل token مولّد (12 طبقة × 12 رأس).
الـ loader يمر على metadata للعثور على hyperparameters النموذج (gpt2.block_count، gpt2.context_length، gpt2.embedding_length، إلخ)، ثم يقرأ جدول معلومات الـ tensor لكي ينتج الاسم، الشكل، dtype، وإزاحة البايت لكل وزن. الأوزان تطابق بأسماء tensors GPT-2 المكتوبة يدوياً ("tok_embeddings.weight"، "blk.{i}.attn.output.weight"، إلخ).
كل كتلة تشفّر 32 قيمة f32 في 34 بايت:
حلقة فك الضغط:
for i in 0..n_blocks {
let d_bits = u16::from_le_bytes(src[block_start..block_start + 2]);
let d = f16::from_bits(d_bits).to_f32();
for j in 0..Q8_0_BLOCK_SIZE {
let q = src[block_start + 2 + j] as i8; // signed int8
dst[out_start + j] = q as f32 * d;
}
}
قيم int8 signed (i8)، النطاق [-128، 127]. معظم تطبيقات GGUF تستخدم signed int8 لـ q8_0. مقياس fp16 يعني إن النطاق القابل للتمثيل لكل كتلة هو تقريباً [-128 × d, 127 × d] حيث d يختلف لكل كتلة. لأوزان GPT-2، الـ scales المعتادة في نطاق 0.001-0.05، معطية تقريباً ±0.1 إلى ±6.4 لكل عنصر وزن.
GGUF يخزن tensors q8_0 و f16 بترتيب column-major. حجم كتلة q8_0 هو 32، والبعد الداخلي يجب أن يكون مضاعفاً لـ 32. لمصفوفة أوزان بشكل [768, 50257]، بعد العمود (50257) هو محور الكتلة، يُحشى لمضاعف 32 على القرص. البايتات الخام موضوعة: كل 768 صفاً من العمود 0، ثم كل 768 صفاً من العمود 1، إلخ.
رأس معلومات الـ tensor يبلغ عن الشكل المنطقي [768, 50257] كـ row-major، لكن المخزن المؤقت المفكوك ضغطه هو column-major. المحمل في الأصل كان ينادي reshape(&[768, 50257])، الذي يفترض row-major، منتجاً مصفوفة أوزان معكوسة. الإصلاح سطر واحد في المحمل: اعكس dims قبل نداء reshape. بعد فك الضغط أو تحويل f16، المحمل يفعل:
// dims = [rows, cols] from the tensor info header
// but data is column-major, so reverse to match:
dims.reverse();
let tensor = CpuTensor::from_data(dims, flat_f32_data);
ثم في Gpt2::from_loader، أوزان الطبقات linear تُعكس مجدداً لاستعادة [in_features, out_features] للـ matmul. الـ embeddings تتخطى الـ transpose لأن عكس dims يجعل index_select يختار صفوفاً متجاورة بالفعل.
خط الـ sampling هو خمس مراحل تطبق بالترتيب:
if temperature > 0.0 {
for l in &mut logits { *l /= temperature; }
}
temperature = 0 يعني greedy argmax، الـ pipeline يتخطى temperature scaling ويذهب مباشرة لـ argmax في categorical_sample. أي قيمة موجبة تقسم الـ logits: t أقل من 1.0 يشحذ (القمم تستخدم وزن نسبي أعلى)، t أكبر من 1.0 يسطح التوزيع (أقرب للانتظام).
let mut indexed: Vec<(usize, f32)> = logits.iter().cloned().enumerate().collect();
indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Equal));
let threshold = indexed[k - 1].1;
for l in logits.iter_mut() {
if *l < threshold { *l = f32::NEG_INFINITY; }
}
يرتب نسخة من أزواج (index، logit) تنازلياً، يقرأ قيمة k-th الأكبر، يقنّع كل شيء تحتها. O(V log V) حيث V = حجم المفردات (50257 لـ GPT-2). لا يفعل شيء عندما k ≥ V أو k = 0.
let soft = softmax_1d(logits); // temporary distribution
let cutoff = nucleus_cutoff(&soft, p); // threshold value
for (i, s) in soft.iter().enumerate() {
if *s < cutoff { logits[i] = f32::NEG_INFINITY; }
}
يحسب softmax مؤقت ليجد أصغر مجموعة tokens يتجاوز احتمالها التراكمي p. الحد الفاصل هو أصغر قيمة احتمال في تلك المجموعة. الـ softmax النهائي يحسب مرة واحدة بواسطة المستدعي (sample_token) بعد تشغيل كل filters، متجنباً softmax مزدوج.
let max = logits.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
if max == f32::NEG_INFINITY {
return vec![1.0 / logits.len() as f32; logits.len()];
}
let exps: Vec<f32> = logits.iter().map(|x| (x - max).exp()).collect();
let sum: f32 = exps.iter().sum();
exps.iter().map(|x| x / sum).collect()
آلية max تمنع overflow: طرح max من كل logit يضمن إن أكبر قيمة بعد exponentiation هي exp(0) = 1. الـ fallback للصف المقنّع بالكامل يرجع توزيع منتظم.
let r: f32 = rng.gen(); // uniform in [0, 1)
let mut cum = 0.0;
for (i, &p) in dist.iter().enumerate() {
cum += p;
if r < cum { return i; }
}
// floating-point rounding fallback: argmax
dist.iter().enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, _)| i).unwrap_or(0)
يمر على المجموع التراكمي للاحتمالات. أول فهرس يتجاوز مجموعه التراكمي السحب العشوائي هو الـ token المختار.
الـ ForwardModel trait في src/model.rs هو واجهة الاستدلال العامة. كلا من Gpt2<B> و Llama<B> ينفذانه:
pub trait ForwardModel<B: Backend> {
fn create_cache(&self, backend: &B, max_seq_len: usize) -> KVCache;
fn forward_with_cache(
&self, backend: &B, token_ids: &[u32],
cache: &mut KVCache, start_pos: usize,
) -> Result<B::Tensor, B::Error>;
fn n_layers(&self) -> usize;
fn embed_dim(&self) -> usize;
fn forward_with_activations(
&self, backend: &B, token_ids: &[u32],
) -> Result<(Vec<Vec<f32>>, B::Tensor), B::Error>;
}
forward_with_cache هو مسار الاستدلال المعياري: embed tokens، مرر عبر transformer blocks مع kv cache، project إلى logits. forward_with_activations هو مدخل الـ probing — نفس forward pass، لكنه يجمع hidden states بعد كل كتلة transformer عند آخر موضع token بدل رميها. هذا لا يستدعيه نمط --probe لاستخراج الـ activations للتحليل اللاحق.
نماذج عائلة llama تختلف عن gpt-2 بأربع طرق هيكلية. كل واحدة تطلبت primitive جديدة على trait Backend.
بدل position embeddings متعلمة، llama يشفّر الموضع بتدوير أزواج من الأبعاد في متجهات query و key. زاوية التدوير لزوج الأبعاد 2i, 2i+1 عند الموضع p هي p / theta^(2i/d) حيث theta عادة 500,000.0 لـ llama 3.
جداول cos/sin تُحسب مسبقاً مرة واحدة في Llama::from_loader عبر compute_rope_freqs(max_seq_len, head_dim, theta_base) وتُنسخ لكل طبقة LlamaAttention. وقت الاستدلال، apply_rotary_emb يدور tensors q و k في مكانها باستخدام هذه الجداول. الحساب المسبق يعني أن RoPE يكلف دوران subspace ثنائي الأبعاد واحد لكل زوج بعد لكل token — لا trigonometry في الـ hot path.
rms_norm يعاير الـ activations بالـ root mean square بدون توسيط:
rms = sqrt(mean(x²) + eps)
y[i] = x[i] * weight[i] / rms
layer norm يطرح المتوسط قبل القسمة على الانحراف المعياري؛ RMS norm يتخطى طرح المتوسط. في الشبكات العميقة، متوسط الـ activations تقريباً صفر على أي حال، فالطرح زائد عن الحاجة. حذفه يوفر طرح واحد لكل عنصر ومرور reduction واحد. llama يستخدم RMS norm قبل attention وقبل mlp في كل block، وكـ output norm نهائي.
شبكة feed-forward في llama تستبدل c_fc → gelu → c_proj في gpt-2 بنسخة مبوبة:
gate = silu(gate_proj(x)) // swish activation على البوابة
up = up_proj(x) // إسقاط خطي
y = gate ⊙ up // جداء عنصري
out = down_proj(y) // الإسقاط النهائي
الجداء العنصري (elemul) هو الـ primitive الجديد. SwiGLU يستخدم مصفوفتي أوزان (gate_proj و up_proj) بدل واحدة (c_fc)، تقريباً يضاعف عدد معاملات mlp لنفس الحجم المخفي. llama يعوض باستخدام حجم وسيط 8/3 * hidden_dim بدل 4 * hidden_dim في gpt-2.
llama يستخدم رؤوس kv أقل من رؤوس query لتقليل ذاكرة cache. llama 3.2 1B يستخدم 32 رأس q و8 رؤوس kv — أربع رؤوس q تتشارك كل زوج kv. الـ KVCache يخزن فقط n_kv_heads k/v لكل طبقة، ليس n_heads.
أثناء attention، رؤوس kv تُكرر لتطابق عدد رؤوس query:
let n_repeat = n_heads / n_kv_heads; // 4 لـ llama 3.2 1B
let kv_h = h / n_repeat; // أي رأس kv يستخدم رأس query h؟
// اقرأ k[kv_h]، v[kv_h] من cache
// احسب dot(q[h]، k[kv_h]) كالمعتاد
لـ gpt-2، n_heads == n_kv_heads فهذا مسار لا يغير السلوك. لـ llama، الـ cache يستخدم ذاكرة أقل بـ 75% (8 رؤوس kv مقابل 32 رأس query)، مما يتيح نوافذ سياق أطول لنفس ميزانية الرام. حجم مخزن مؤقت qk_scratch لا يتغير لأن scores qk هي لكل رأس query.
نمط probe (--probe) هو ميزة بحثية تستخرج hidden states من كل كتلة transformer. التدفق:
forward_with_activations يشغّل نفس forward pass للاستدلال لكنه يجمع hidden states بعد كل block عند آخر موضع token. المخرج هو Vec<Vec<f32>> حيث activations[layer][d] هو قيمة hidden state للبعد d في الطبقة layer. هذه تُسطح وتُصف في مصفوفة 3d وتحفظ بصيغة numpy (npy.) format — يمكن تحميلها مباشرة من سكريبتات python.
بالإضافة لـ .npy، نمط probe يكتب ملف _correctness.json يتتبع أي stimulus أنتج الاستمرارية المتوقعة بالنمط (مفيد لقياس ما إذا كان النموذج قد تعلم مورفولوجيا عربية سطحية). الـ forward_with_activations منفذ لكلا Gpt2 وLlama عبر trait ForwardModel، لذا نمط probe يعمل مع أي معمارية.
wall-clock profiling لخطوة decode واحدة (GPT-2 small، x86_64، release build). القياسات من std::time::Instant مع microbenchmark من 1000 عينة:
matmul يهيمن. c_fc و output projection هما أثقل matmuls، 768×3072 و 3072×768 على التوالي، كل واحد 2.36 مليون ضرب-جمع. SIMD سيخفض هذا لـ تقريباً 8-10 ms، دافعاً throughput نحو 40-50 tok/s.
صفوف softmax المقنّعة بالكامل. causal mask يضبط المواضع المستقبلية لـ -inf. في أول خطوة decode (token واحد فقط في التسلسل)، كل صف بعد الأول هو بالكامل -inf. standard softmax ينتج NaN ((-inf - -inf).exp() يقيّم لـ NaN حسب ieee 754). تفرع واحد، if max == f32::NEG_INFINITY { return uniform }، يمنع NaN من الانتشار عبر 12 طبقة إلى logits المخرج.
temperature = 0. الـ sampler يتحقق if temperature > 0.0 ويتخطى الـ scaling. في categorical_sample، توزيع softmax لـ logits حادة يركز كل الكتلة على token argmax، و inverse cdf sampling يختاره حتمياً. هكذا ينتج --temperature 0 مخرجاً متطابقاً كل تشغيلة (مع نفس rng seed).
resize مخزن مؤقت scratch. مخزن مؤقت qk_scratch مخصص لـ max_seq_len (2048). أثناء decode في الموضع 47، المستدعي يعيد تحجيمه لـ 48. لأن 48 ≤ 2048، الـ resize هو no-op، السعة حُددت في وقت البناء. هذا ثابت متعمد: الـ hot path لا يستدعي allocator العام أبداً.
transposed embeddings. ملف gguf يخزن embeddings الـ tokens كـ [vocab, embed] (50257 × 768). المحمل يعكسهم لـ [embed, vocab] (768 × 50257) أثناء بناء النموذج. هذا يعني index_select(token_id) يختار شريحة متجاورة من 768 عنصر بدلاً من جمع 768 عنصراً متباعداً بخطوة vocab_size. التكلفة: transpose واحد عند البدء. الفائدة: كل خطوة inference تعمل memcpy بدل gather.