← voidwest    research    engineering

ember internals

هذا تفكيك قريب لهياكل البيانات والخوارزميات وتخطيطات الذاكرة التي تشغّل ember. يفترض إنك قريت الـ architecture writeup وترغب التفاصيل التقنية.

architecture

المشروع صغير بما يكفي لتتبعه من CLI إلى logits. main.rs يملك generation وlogits dump وprobe modes. عوائل النماذج تنفذ ForwardModel. عمليات compute تمر عبر Backend، و CpuBackend هو runtime الحالي.

main.rscli args، generation، logits dump، probe modes
├─ loader.rsgguf v3 parser، metadata، tensor loading
├─ model.rsgpt-2 blocks و ForwardModel trait
├─ llama.rsمسار llama/qwen-family
├─ gemma4.rsمسار gemma 4 dense text-only
├─ backend.rsbackend trait، cpu backend، q8_0، attention helpers
├─ tensor.rsrow-major f32 tensor operations
├─ kv_cache.rsflat k/v cache و scratch buffers
└─ probes/probing، validation، reports

design decisions

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 claimsscripts تكتب summaries قابلة للقراءة آلياً قبل التفسير.

math primitives

الحسابات الأساسية عادية لكنها حساسة: softmax يحتاج fallback للصفوف masked بالكامل، وlayer norm/RMS norm يجب أن يرجعوا إلى block stream، وRoPE conventions إذا اختلفت تنتج نصاً يبدو ممكناً لكنه خطأ.

attention

attention ينقسم إلى q/k/v projections، positional handling أو RoPE، causal mask، softmax، ثم weighted value accumulation. الـ cache يحول decode إلى prefill واحد وخطوة cached لكل token جديد.

bugs and first light

أكثر الأخطاء فائدة لم تكن crashes. layout خاطئ في GGUF أنتج نصاً عشوائياً، وbug في KV-cache prefill جعل النموذج يرى آخر token فقط. أول output متماسك كان smoke evidence فقط، وليس golden validation.

The cheese snowball Cheese Cheese Cheese Cheese Cheese Cheese Cheese Cheese Cheese Cheese Cheese Cheese Cheese Cheese Cheese Cheese
ember v0.1، greedy GPT-2 output بعد إصلاح column-major layout

tensor

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: matrixmultiply::sgemm

الـ 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

الـ 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.

kv cache

layout

k: Vec<f32> // flat [layer][head][pos][head_dim] v: Vec<f32> // same layout offset math: layer_offset = layer * n_heads * max_seq_len * head_dim head_offset = head * max_seq_len * head_dim pos_offset = pos * head_dim absolute = layer_offset + head_offset + pos_offset

ليش هذا الـ 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 semantics

الـ 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.

qk_scratch

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 رأس).

GGUF format and quantization

file structure

offset field 0x00 magic: 0x47 0x55 0x46 0x47 ("GGUF") 0x04 version: u32 (3) 0x08 n_tensors: u64 0x10 n_metadata: u64 0x18 metadata kv pairs (n_metadata entries) ... tensor info table (n_tensors entries) ... tensor data (raw bytes at offsets from info table)

الـ 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"، إلخ).

q8_0 block quantization

كل كتلة تشفّر 32 قيمة f32 في 34 بايت:

bytes 0-1: d (fp16 scale factor) bytes 2-33: q[0..31] (int8 quantized values) reconstruction: dst[j] = q[j] as f32 * d.to_f32()

حلقة فك الضغط:

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 لكل عنصر وزن.

the column-major trap

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 يختار صفوفاً متجاورة بالفعل.

sampler

خط الـ sampling هو خمس مراحل تطبق بالترتيب:

1. temperature

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 يسطح التوزيع (أقرب للانتظام).

2. top-k

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.

3. top-p (nucleus)

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 مزدوج.

4. 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 للصف المقنّع بالكامل يرجع توزيع منتظم.

5. categorical sample (inverse cdf)

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

الـ 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 architecture details

نماذج عائلة llama تختلف عن gpt-2 بأربع طرق هيكلية. كل واحدة تطلبت primitive جديدة على trait Backend.

rotary position embeddings (RoPE)

بدل 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

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 نهائي.

SwiGLU mlp

شبكة 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.

grouped-query attention (GQA)

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.

probing pipeline

نمط probe (--probe) هو ميزة بحثية تستخرج hidden states من كل كتلة transformer. التدفق:

stimuli json (مثلاً nonce_root_pattern.json) │ ▼ tokenize كل stimulus model.forward_with_activations(token_ids) │ ▼ جمع per-layer activations عند آخر موضع token حفظ كـ .npy (الشكل: [n_stimuli، n_layers، embed_dim]) │ ▼ سكريبتات python probe train_linear_probe.py → مصنف logistic regression cca_analysis.py → canonical correlation مع سمات لغوية rsa_analysis.py → representational similarity matrices divergence_analysis.py → JS divergence لكل طبقة

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 يعمل مع أي معمارية.

profiling notes

wall-clock profiling لخطوة decode واحدة (GPT-2 small، x86_64، release build). القياسات من std::time::Instant مع microbenchmark من 1000 عينة:

decode step total (~84 ms) ──────────────────────────────────── matmul (12 layers × 4 matmuls): ~55 ms (66%) c_attn (768→2304): ~14 ms c_proj (768→768): ~6 ms c_fc (768→3072): ~19 ms c_proj (3072→768): ~16 ms attention (scalar loops): ~12 ms (14%) gelu (12×3072 elements): ~5 ms (6%) layer_norm (12×2×768 elements): ~4 ms (5%) softmax (12 heads × 64 dim): ~3 ms (4%) other (adds, copies, sampling): ~4 ms (5%)

matmul يهيمن. c_fc و output projection هما أثقل matmuls، 768×3072 و 3072×768 على التوالي، كل واحد 2.36 مليون ضرب-جمع. SIMD سيخفض هذا لـ تقريباً 8-10 ms، دافعاً throughput نحو 40-50 tok/s.

edge cases worth knowing

صفوف 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.