🦙 llama.cpp 图解教程llama.cpp Visual Guide 第二部分 · 前置基础Part 2 · Foundations 05 / 40
第二部分 · 前置基础Part 2 · Foundations

张量是什么What is a tensor

ggml 是 llama.cpp 的计算引擎,而在它眼里一切数据都是"张量"——权重、激活值、KV cache,全是张量。 这一课把 ggml_tensor 这个结构体讲到"摸得着":什么是 shape、什么是 stride、为什么 ggml 用"行优先", 以及为什么"转置一个张量"几乎不花钱。看懂这一层,后面所有计算图和算子的示意图你都能读懂;可以说,张量是读 ggml 源码的"第一块积木"。

🔌 生活类比
把张量想成一排储物柜阵列ne[] 告诉你"每排几个柜、一共几排"(形状), nb[] 告诉你"从一个柜走到下一个、或跳到下一排,各要迈多少步"(步长,单位是字节), data 则是这排柜子的起点地址。知道这三样,你就能算出任意一个柜子在哪。 而"柜子里装的是什么规格"(type)则决定每个格子占多大、怎么读。把"形状(柜子怎么排)"和"数据(柜子里的东西)" 分开记——这正是后面所有省内存、零拷贝把戏的根。

张量 = 形状 + 类型 + 一块连续内存

一个张量说白了就是"一块连续内存 + 一份怎么解释它的说明书"。说明书里有:数据类型type,比如 F32、F16、Q4_0)、每个维度有多少元素ne[],ggml 最多 4 维), 以及一个指向那块内存的指针 data。先看"形状"长什么样:

形状 ne:一个 ne=[4,3] 的张量 - ne[0]=4 是"一排 4 个"(最内维),ne[1]=3 是"共 3 排"
第 0 排a00a01a02a03
第 1 排a10a11a12a13
第 2 排a20a21a22a23

顺便厘清"几维"这件事:0 维是一个标量(一个数),1 维是一个向量(一串数,比如一个词向量), 2 维是一个矩阵(比如一层的权重),3 维、4 维则常见于"带批次、带多头"的中间结果。ggml 看 ne[] 里实际大于 1 的维数来判断张量是几维;没用到的高维就填 1。所以当你看到 ne=[4,3,1,1] 时,它其实就是个 4×3 的二维张量。

注意 ggml 的约定:ne[0]最内层、变化最快的维度(在内存里挨着摆),ne[1] 是"下一排", 以此类推。这和很多人习惯的"行 × 列"写法正好相反——后面有专门的深挖讲这个坑。把 ggml_tensor 的核心字段摊开看,就这么几样:

struct ggml_tensor {
    enum ggml_type   type;           // F32 / F16 / Q4_0 ...
    int64_t          ne[4];          // #elements per dim (ne[0] = innermost)
    size_t           nb[4];          // byte strides
    enum ggml_op     op;             // how it was produced (graph node)
    struct ggml_tensor * src[...];    // inputs (back-pointers)
    struct ggml_tensor * view_src;     // set if this tensor is a view
    void *           data;           // the actual bytes
    char             name[...];
};  // 简化自 ggml/include/ggml.h, GGML_MAX_DIMS = 4

这里有两组字段值得记住。ne / nb / type / data 描述"这块数据长什么样、在哪";而 op / src 描述"它是怎么算出来的"—— op 是产生它的算子(比如矩阵乘),src 是指向输入张量的反向指针。后面这组正是 ggml "先建计算图、再执行"的关键:每个张量都记得自己的来历,把它们顺着 src 串起来,就是一张计算图 (课 03 提过、第三部分会展开)。另外别忽略 type:同样 1000 个元素,F32 占 4000 字节、Q4_0 只占 500 多字节—— 类型直接决定了这块内存有多大,这也是量化能省显存的根。

🌍 宏观理解
抽象归抽象,张量到底用来装什么?在 llama.cpp 里,模型的每个权重矩阵是张量(比如词嵌入表是一个[n_embd, n_vocab] 的大张量、每层的注意力与 FFN 权重也都是张量),前向过程中流动的激活值是张量,连 KV cache 里存的那些 K 和 V 也是张量。可以说,一次推理从头到尾,就是一堆张量按计算图被算来算去。正因为如此,把"张量"这个抽象设计得又轻又灵活,对整个引擎的效率至关重要。

再说说 type。ggml 支持一长串数据类型:全精度的 F32、半精度的 F16 / BF16,以及一大家子量化类型Q4_0Q8_0Q4_K 等等,下一课专门讲)。同一个张量结构、同一套 ne/nb 逻辑,能装下这么多种类型,靠的就是用 type 这一个字段统一描述"每个元素(或每块)多少字节、怎么解释"。这种"结构不变、类型可换"的设计,让量化能无缝接进 已有的张量与算子体系,而不必为每种精度各写一套。也正因如此,换个量化等级(比如从 Q4_0 换到 Q5_K) 对上层代码几乎是透明的——变的只是 type 和每块的字节数,ne/nb 的那套逻辑原封不动。

🔬 细节 / 源码对应
张量从哪来?在 ggml 里,你先开一个 ggml_context(一个内存池),再用 ggml_new_tensor_2d这类函数在池子里"登记"一个张量——它会按 type 和 ne 算好需要多少字节、把 nb 填好。值得注意的是:建张量时通常并不立刻搬运那一大块数据,很多时候只是先把"形状说明书"建好(这正配合了 ggml 先建图、后执行的风格),真正的内存分配与计算留到后面统一来做。第三部分会专门讲 ggml_context 与这套"先描述、后执行"的内存管理。

有人可能会问:装个多维数组,直接用 C++ 的 std::vector 或裸数组不就行了,何必单独造一个 ggml_tensor?因为 ggml 要的远不止"存一堆数":它要能把运算描述成图(靠 op/src)、要能 让数据躺在不同后端的内存上(靠 buffer)、要能统一容纳量化等多种类型(靠 type 和按块的 nb)、还要能 零拷贝地变形(靠 ne/nb 与 view_src)。这些需求叠在一起,才有了这个看似简单、其实精心设计的结构体——它是整个引擎的"原子"。

行优先与 stride:nb[] 是怎么算的

内存其实是一维的(一长条字节),可张量是多维的,把多维"压平"到一维靠的就是 stride(步长)。 ggml 用 nb[i] 记录"第 i 维每加 1,要在内存里跳过多少字节"。ggml 是行优先的: ne[0] 那一维在内存里连续摆放、步长最小。看一个 ne=[3,2](每排 3 个、共 2 排)的 F32 张量是怎么躺在内存里的:

行优先内存布局:在内存里是一条连续字节流,先摆满第 0 排,紧接着第 1 排(数字是字节偏移)
元素a00a01a02a10a11a12
偏移048121620

看这条字节流就懂了:同一排里相邻元素隔 4 字节(一个 F32),所以 nb[0]=4;从第 0 排跳到第 1 排 (高亮那个 a10)要跨过整整一排 3 个元素 = 12 字节,所以 nb[1]=12。这套规则写成公式,就藏在 ggml.h 的注释里:nb[0]=ggml_type_size(type)(一个元素多少字节)、 nb[1]=nb[0]*(ne[0]/ggml_blck_size(type))(跨一整排的字节,普通类型就是"每元素字节 × 一排元素数")、 再往上 nb[i]=nb[i-1]*ne[i-1](严格说公式里还有一个"对齐填充"项,连续、无填充的张量这一项为 0,这里略去)。于是给任意一个多维下标,把它和 nb 点乘一下,就得到字节偏移:

多维下标
(i0, i1, i2, i3)
->
点乘步长
Σ i_k × nb[k]
->
字节偏移
offset
->
取到元素
data + offset
# 多维下标 (i0, i1, i2, i3) -> 内存里的字节偏移
offset = i0*nb[0] + i1*nb[1] + i2*nb[2] + i3*nb[3]
ptr    = (char*)tensor->data + offset   # 就是这个元素的地址

为什么是 4 维GGML_MAX_DIMS = 4)?因为推理里的张量基本不超过 4 维——典型的就像 "批次 × 序列 × 头数 × 每头维度"这种组合,4 维足够覆盖;用一个定长的小数组存 ne/nb,既简单又快,不必动态分配。 还有一个容易忽略的点:data 指向的内存不一定在 CPU 上——它可能在 GPU 显存里,张量另有一个 buffer 字段记录"这块数据归哪个后端管",这正好呼应课 07 要讲的多后端(同一个张量结构,数据可以躺在不同硬件上)。

把前面的公式用一个具体例子走一遍,印象会更深。还用上面那张内存图里的 ne=[3,2] 的 F32 张量:每个元素 4 字节,于是 nb[0]=4;跨一整排要越过 3 个元素,于是 nb[1]=4×3=12。想取第 1 排第 2 个元素(下标 i1=1, i0=2,从 0 数起,也就是图里的 a12),它的字节偏移就是 1×nb[1] + 2×nb[0] = 12 + 8 = 20—— 正好是内存图里 a12 底下标的那个 20。整块张量一共 3×2×4 = 24 字节。你看,只要有 ne、nb 和 data,任意一个元素的地址 都能一步算出来,这就是 stride 的全部威力;至于 ne=[4,3] 之类的别的形状,留给本课末尾的思考题自己算一遍。

反过来,知道了字节布局,你也就明白了为什么"按行遍历"通常比"按列遍历"快:顺着 ne[0](行内)走, 访问的是内存里连续相邻的字节,对 CPU 缓存最友好;而按高维跳着走,每次都跨一大步,更容易频繁缺失缓存、拖慢速度。 很多算子实现都刻意顺着连续维来安排循环,正是这个道理——布局决定性能,这条直觉在后面看内核实现时还会反复用到。

view / 转置为什么不拷贝数据

现在来看 ggml 一个非常"省"的设计。既然"形状(ne/nb)"和"数据(data)"是分开存的,那么很多"改变形状" 的操作,根本不用动数据,只要改几个数字。最典型的就是转置:把一个 [行, 列] 的矩阵转成 [列, 行],ggml 只是 交换 ne[0] 与 ne[1]、同时交换 nb[0] 与 nb[1]data 一个字节都不搬:

原张量

ne=[3,2]nb=[4,12]
data -> 一块真实内存

转置后(一个 view)

ne=[2,3]nb=[12,4]
view_src -> 指回原张量,data 不变

⚠ 注意
像 reshape、转置、切片、广播这类操作,ggml 大多用视图(view)实现:新张量复用同一块 data,只是带上不同的ne/nb 和偏移,并用 view_src 记住"我是谁的视图"。好处显而易见:零拷贝、省内存、还快。代价是:视图常常变得不连续ggml_is_contiguous 为假)——比如转置之后,沿 ne[0]方向走,在内存里就不再是挨着的了。有些算子要求输入必须连续,这时要先用 ggml_cont 把它"压实"成一块新的连续内存(注意:这一步才真的发生拷贝)。所以你会在 ggml 代码里看到不少 ggml_cont 的调用,它们就是在"连续性"和"零拷贝"之间做权衡。

顺带提一个常见操作:广播(broadcast)。当两个形状不完全相同的张量要做逐元素运算(比如给每一行都加上同一个偏置向量), ggml 允许某些维度上"一个元素当很多元素用"——靠的还是 stride 的小把戏:把那一维的 nb 设成 0,下标怎么变、 地址都不动,于是同一个值被反复读取,看起来就像"复制"了一遍,实际上一个字节都没多占。这又是一次"改 nb 而不搬 data"的典型, 和 view、转置一脉相承。

就拿转置来说,把它摊开看最清楚:同一排内存,原张量横着读、转置竖着读,6 个值一个都没动。

追踪一次转置:[3,2] 变 [2,3] 只是换了 ne/nb 怎么读这块内存,底层 6 个字节一个都没搬。
原始 ne=[3,2] nb=[4,12] 转置 ne=[2,3] nb=[12,4] a b c d e f a d b e c f a +0 b +4 c +8 d +12 e +16 f +20 底层内存:6 个值一个都没搬(offset 单位:字节) 同一个 b:在两种网格里位置不同,指向的却是同一块内存

一个最常见的 reshape 例子:把形状 [n_embd, n_tokens] 的激活,按多头注意力的需要"摊"成 [head_dim, n_head, n_tokens]——元素总数没变(n_embd = head_dim × n_head),数据也没搬, 只是重新解释了 ne/nb,就把"一个大向量"看成了"若干个头各自的小向量"。课 04 说的多头注意力里,大量这种"同一块数据、换个形状看"的操作, 靠的全是视图,几乎不产生额外拷贝。

🔬 细节 / 源码对应
再比如切片:想从一大块张量里取出"第 k 层"或"第 h 个注意力头"那一小块,ggml 通常也不复制,而是算好一个起始偏移、配上裁剪过的 ne/nb,返回一个指回原数据的视图。所以在 ggml 代码里你会发现,"取一部分"和"换个形状看"在底层往往是同一种廉价操作——这套以视图为中心的玩法,是读懂后面计算图代码的一把钥匙。

顺便说清"连续"到底指什么:一个张量连续,意思是它的元素在内存里就是紧挨着、按 ne[0]、ne[1]… 顺序一个不落地排的 (也就是 nb 严格按前面的公式递推)。转置、某些切片会打破这种整齐:元素还是那些元素,但"走的顺序"和"内存摆放"对不上了,于是 ggml_is_contiguous 返回假。多数算子能直接吃连续张量;遇到必须连续的场合,ggml_cont 会按当前形状把数据重新誊抄成一块整齐的新内存。记住这条,你调试形状相关的问题时会少踩很多坑。

🌍 宏观理解
最后回头看 opsrc 这两个字段,它们让张量有了"双重身份":既是一块数据,又是计算图里的一个节点。当你写下 c = ggml_mul_mat(ctx, a, b),ggml 并不立刻算矩阵乘,而是新建一个张量c,把它的 op 记成"矩阵乘"、src[0]/src[1] 指向 ab。于是整张计算图,其实就是张量们靠 src 互相牵着手连成的一张网;等图建完,再交给后端按这张网的顺序逐个算过去。理解了张量这层"图节点"身份,你就握住了第三部分 ggml 引擎的钥匙。

结构体里还有个不起眼但很实用的字段 name:每个张量可以带一个名字。这在调试时很有用(打印计算图时一眼认出 "这是哪个权重"),而且 GGUF 文件里的每个权重张量本来就是带名字存的(像 blk.0.attn_q.weight 这种), 加载时按名字对号入座。所以"名字"不只是注释,它是模型权重和代码之间的索引。你在 GGUF 工具或调试日志里看到的那一串张量名,正是来自这个字段。

💡 实战
给个实战提示:在 ggml 里写代码,最常见的报错就是形状对不上——矩阵乘要求左右两个张量在相乘的那一维上元素数相等,转置、reshape 之后维度顺序变了,很容易把该对齐的维弄错。养成随手在脑子里写出每个张量 ne 的习惯(甚至用 name标注、打印出来核对),能帮你省下大量调试时间。这也是为什么这一课要把 ne/nb 讲得这么细:形状是 ggml 编程的"语法",语法错了,后面什么都跑不起来。

深入一点(选读)

下面三个问题,想深究的同学点开看;只想抓主线的可以先跳过。

1 ggml 的维度顺序为什么和 PyTorch 相反? 点击展开

在 numpy / PyTorch 里,习惯把最后一维当作内存里连续的维度(行优先、C-order):一个形状 [batch, seq, dim] 的张量,dim 是连续的。ggml 反过来:它把连续的那一维放在 ne[0](最前面),所以同一个张量在 ggml 里写成 ne = [dim, seq, batch]——维度顺序整个反过来

这不是谁对谁错,只是约定不同;但读 ggml 代码、看张量形状时一定要在脑子里切换过来,否则很容易把行当成列、把 batch 看成 dim。 一个好记的口诀:ggml 的 ne[0] 永远是"最贴着内存、变化最快"的那一维

2 nb 公式里为什么有个 /blck_size?量化类型的坑 点击展开

普通类型(F32、F16)里,nb[0] 就是一个元素的字节数。但量化类型(如 Q4_0)不是"一个元素一个值", 而是把一整块(如 32 个权重)打包压成定长字节,单个权重没法独立寻址

所以 ggml 用 ggml_blck_size(type)(一块里有几个元素)和 ggml_type_size(type)(一块多少字节) 来描述。nb[1] 公式里那个 ne[0] / ggml_blck_size(type),意思就是"这一排里有多少"。 明白这点,你就懂了为什么量化张量不能像普通数组那样随便按单元素下标去取——得按块解量化(第三部分的量化格式课会细讲)。

3 怎么算一个张量占多少内存? 点击展开

直接用 ggml_nbytes(tensor)。直觉上,一个连续张量的字节数约等于"最高维元素数 × 最高维步长" (ne[k] * nb[k] 取最高维),也就是把各维元素数乘起来、再乘上每元素(或每块)的字节数。

这在估算显存占用时很有用:模型权重占多少、KV cache 占多少,本质上都是这么一类张量的字节数加总。非连续张量、量化张量的算法略有不同, 但 ggml_nbytes 已经替你把这些情况都处理好了,直接调用即可。

✅ 关键要点
  • 张量 = type(类型)+ ne[4](形状)+ nb[4](字节步长)+ data(内存)+ op / src(它在计算图里怎么来的)。
  • ggml 是行优先ne[0] 是最内 / 连续维(步长最小),维度顺序和 numpy / PyTorch 相反
  • 多维下标 -> 内存偏移:offset = Σ i_k × nb[k]
  • view / 转置 / reshape 零拷贝:只改 ne/nb、复用 data、用 view_src 记来源;代价是可能非连续,必要时 ggml_cont 压实(这才真拷贝)。
  • 量化类型按存储,nb 公式里的 /blck_size 就是这个原因。
💡 设计洞察
把"形状"(ne/nb)和"数据"(data)彻底分开存——就这一个设计,让转置、切片、reshape、广播统统变成"改几个数字" 而非"搬一整块内存",也让同一块权重能被计算图以不同视角反复使用。ggml 的高效,很大一部分就藏在这个朴素的拆分里。下一课讲量化,你会看到这套 ne/nb/type 的设计,如何让"把权重压成 4 bit"这件事, 能不动声色地接进同一套张量与算子里。

🧪 自测 · 想一想为什么这么设计

1. 在 ggml 里把一个张量“转置”,主要改变了什么?
  1. 重新量化了权重
  2. 只交换 ne[]/nb[](步长),复用同一块 data,不搬数据
  3. 改变了 type
  4. 复制出一块新内存
看答案与解析 点击展开
答案:B。转置是改元数据的视图操作:交换 ne/nb、用 view_src 指回原张量,data 一个字节都不动,所以零拷贝。
2. ggml 张量里哪一维是“最内/连续(步长最小)”的维?
  1. 都一样
  2. ne[0]
  3. 由 type 决定
  4. ne[3]
看答案与解析 点击展开
答案:B。ggml 行优先,nb[0]=ggml_type_size(type) 最小,ne[0] 在内存里连续摆放;这和 numpy/PyTorch 的约定相反。
3. 为什么量化类型(如 Q4_0)的张量不能像普通数组那样按单个元素下标随便取?
  1. 因为它按“块”打包存储,单个权重无法独立寻址,要按块解量化
  2. 因为它只能有一维
  3. 因为它没有 ne 字段
  4. 因为它的 data 是空的
看答案与解析 点击展开
答案:A。量化类型把一整块(如 32 个权重)压成定长字节,nb 公式里的 ne[0]/ggml_blck_size(type) 正是“一排有多少块”。
💭 发散思考(没有标准答案,动手或动脑想想)
  • 给一个 ne=[4,3](ne[0]=4)的 F32 张量,nb[0] 与 nb[1] 各是多少字节?(F32 = 4 字节)

ggml is llama.cpp's compute engine, and in its eyes all data is "tensors" - weights, activations, the KV cache, all tensors. This lesson makes the ggml_tensor struct tangible: what shape is, what stride is, why ggml is "row-major", and why "transposing a tensor" costs almost nothing. Understand this layer and you can read every compute-graph and operator diagram that follows; tensors are, you could say, the first building block for reading ggml source.

🔌 Analogy
Think of a tensor as a grid of lockers: ne[] tells you "how many lockers per row, how many rows" (shape), nb[] tells you "how many steps to walk from one locker to the next, or to jump to the next row" (strides, in bytes), and data is the starting address of the grid. Know those three and you can locate any locker. And "what spec the lockers hold" (type) decides how big each cell is and how to read it. Keep "shape (how lockers are arranged)" and "data (what's inside)" separate - that is the root of every memory-saving, zero-copy trick later.

A tensor = shape + type + one contiguous block of memory

A tensor is really "one contiguous block of memory + a manual for how to read it". The manual has: the data type (type, e.g. F32, F16, Q4_0), how many elements per dimension (ne[], up to 4 dims in ggml), and a pointer data to that memory. First, what "shape" looks like:

shape ne: a tensor with ne=[4,3] - ne[0]=4 is "4 per row" (innermost dim), ne[1]=3 is "3 rows"
row 0a00a01a02a03
row 1a10a11a12a13
row 2a20a21a22a23

A quick clarification of "how many dims": 0-D is a scalar (one number), 1-D a vector (e.g. one embedding), 2-D a matrix (e.g. a layer's weights), and 3-D / 4-D show up in "batched, multi-head" intermediates. ggml infers a tensor's rank from how many of ne[] are greater than 1; unused higher dims are filled with 1. So ne=[4,3,1,1] is really just a 4x3 two-dimensional tensor.

Note ggml's convention: ne[0] is the innermost, fastest-changing dimension (laid out contiguously in memory), ne[1] is "the next row", and so on. This is the opposite of the "rows x cols" ordering many people are used to - a dedicated deep-dive below covers this trap. Here are the core fields of ggml_tensor:

struct ggml_tensor {
    enum ggml_type   type;           // F32 / F16 / Q4_0 ...
    int64_t          ne[4];          // #elements per dim (ne[0] = innermost)
    size_t           nb[4];          // byte strides
    enum ggml_op     op;             // how it was produced (graph node)
    struct ggml_tensor * src[...];    // inputs (back-pointers)
    struct ggml_tensor * view_src;     // set if this tensor is a view
    void *           data;           // the actual bytes
    char             name[...];
};  // simplified from ggml/include/ggml.h, GGML_MAX_DIMS = 4

Two groups of fields are worth remembering. ne / nb / type / data describe "what this data looks like and where it is"; while op / src describe "how it was computed" - op is the operator that produced it (e.g. matmul), src are back-pointers to input tensors. That second group is the key to ggml's "build the graph first, then execute": every tensor remembers its origin, and stringing them together via src is a compute graph (mentioned in lesson 03, expanded in Part 3). Don't overlook type either: the same 1000 elements take 4000 bytes as F32 but only ~500 as Q4_0 - the type alone decides how big this memory is, which is the root of how quantization saves memory.

🌍 Big picture
Abstractions aside, what do tensors actually hold? In llama.cpp, every weight matrix is a tensor (the embedding table is a big [n_embd, n_vocab] tensor; each layer's attention and FFN weights are tensors too), the activations flowing through the forward pass are tensors, and even the K and V stored in the KV cache are tensors. An entire inference, start to finish, is just a pile of tensors being computed along a graph. That is why making the "tensor" abstraction light and flexible is so crucial to the whole engine's efficiency.

About type: ggml supports a long list of data types - full-precision F32, half-precision F16 / BF16, and a whole family of quantized types (Q4_0, Q8_0, Q4_K, etc. - next lesson). The same tensor struct and the same ne/nb logic hold all of them, because one field, type, uniformly describes "how many bytes per element (or per block), and how to interpret them". This "same structure, swappable type" design lets quantization plug seamlessly into the existing tensor and operator machinery without rewriting it per precision. Because of this, switching quantization levels (say Q4_0 to Q5_K) is nearly transparent to higher-level code - only the type and per-block byte count change; the ne/nb logic is untouched.

🔬 Details / source
Where do tensors come from? In ggml you first open a ggml_context (a memory pool), then use functions like ggml_new_tensor_2d to "register" a tensor in the pool - it computes the needed bytes from type and ne, and fills in nb. Notably, creating a tensor usually does not immediately move that big block of data; often it just sets up the "shape manual" (matching ggml's build-graph-then-execute style), leaving the real allocation and computation for a unified later pass. Part 3 covers ggml_context and this "describe first, execute later" memory management.

You might ask: to hold a multi-dim array, why not just use C++'s std::vector or a raw array - why a dedicated ggml_tensor? Because ggml needs far more than "store some numbers": it must describe computation as a graph (via op/src), let data live in different backends' memory (via buffer), uniformly hold many types including quantized ones (via type and per-block nb), and reshape with zero copies (via ne/nb and view_src). All these needs together produced this seemingly simple but carefully designed struct - the engine's "atom".

Row-major and stride: how nb[] is computed

Memory is actually one-dimensional (a long strip of bytes), but tensors are multi-dimensional; what flattens many dims into one is stride. ggml uses nb[i] to record "how many bytes to skip in memory when dim i increases by 1". ggml is row-major: the ne[0] dimension is laid out contiguously with the smallest stride. Here is how an ne=[3,2] (3 per row, 2 rows) F32 tensor lies in memory:

Row-major layout: one contiguous byte stream - fill row 0, then row 1 (numbers are byte offsets)
elema00a01a02a10a11a12
offset048121620

This byte stream makes it click: adjacent elements in a row are 4 bytes apart (one F32), so nb[0]=4; jumping from row 0 to row 1 (the highlighted a10) skips a whole row of 3 elements = 12 bytes, so nb[1]=12. As a formula - straight from the comments in ggml.h: nb[0]=ggml_type_size(type) (bytes per element), nb[1]=nb[0]*(ne[0]/ggml_blck_size(type)) (bytes to cross one row; for plain types just "bytes-per-element x elements-per-row"), and above that nb[i]=nb[i-1]*ne[i-1] (strictly there is also an alignment "padding" term, which is 0 for contiguous unpadded tensors and is omitted here). So given any multi-dim index, dot it with nb to get the byte offset:

multi-dim index
(i0, i1, i2, i3)
->
dot with strides
sum i_k * nb[k]
->
byte offset
offset
->
reach element
data + offset
# multi-dim index (i0, i1, i2, i3) -> byte offset in memory
offset = i0*nb[0] + i1*nb[1] + i2*nb[2] + i3*nb[3]
ptr    = (char*)tensor->data + offset   # the address of this element

Why 4 dims (GGML_MAX_DIMS = 4)? Because inference tensors rarely exceed 4 dims - a typical shape is "batch x sequence x heads x per-head-dim", and 4 is enough; storing ne/nb in a small fixed-size array is simple and fast, with no dynamic allocation. One more easily-missed point: the memory data points to is not necessarily on the CPU - it may live in GPU memory, and the tensor has a separate buffer field recording "which backend owns this data". That foreshadows the multi-backend story of lesson 07 (the same tensor struct, with data living on different hardware).

Walking the formula through a concrete example makes it stick. Using the same ne=[3,2] F32 tensor from the memory diagram above: each element is 4 bytes, so nb[0]=4; crossing a whole row skips 3 elements, so nb[1]=4x3=12. To fetch row 1, element 2 (index i1=1, i0=2, zero-based - a12 in the diagram), the byte offset is 1*nb[1] + 2*nb[0] = 12 + 8 = 20 - exactly the 20 marked under a12. The whole tensor is 3x2x4 = 24 bytes. So with ne, nb, and data, any element's address is one step away - that is the full power of stride; other shapes like ne=[4,3] are left for this lesson's closing exercise.

Conversely, knowing the byte layout explains why "row-major traversal" is usually faster than "column-major": walking along ne[0] (within a row) touches contiguous, adjacent bytes, friendliest to the CPU cache; jumping along higher dims takes a big stride each time and misses the cache more often. Many operator implementations deliberately loop along the contiguous dimension for exactly this reason - layout decides performance, an intuition we will reuse when reading kernel implementations later.

Why view / transpose copy no data

Now a very "thrifty" ggml design. Since "shape (ne/nb)" and "data" are stored separately, many "reshape" operations need not touch the data at all - just change a few numbers. The classic is transpose: turning a [rows, cols] matrix into [cols, rows], ggml merely swaps ne[0] with ne[1] and nb[0] with nb[1], moving data by not a single byte:

Original

ne=[3,2], nb=[4,12]
data -> a real block of memory

Transposed (a view)

ne=[2,3], nb=[12,4]
view_src -> points back, data unchanged

⚠ Heads-up
Operations like reshape, transpose, slice, and broadcast are mostly implemented as views in ggml: the new tensor reuses the same data, just with different ne/nb and an offset, recording its origin in view_src. The upside is obvious: zero-copy, memory-saving, and fast. The cost: views often become non-contiguous (ggml_is_contiguous is false) - e.g. after a transpose, walking along ne[0] is no longer adjacent in memory. Some operators require contiguous inputs, in which case you first call ggml_cont to "compact" it into a fresh contiguous block (note: this step does copy). That is why you see many ggml_cont calls in ggml code - balancing "contiguity" against "zero-copy".

One common operation in passing: broadcast. When two not-quite-same-shape tensors do an element-wise op (e.g. adding the same bias vector to every row), ggml lets some dimension use "one element as many" - again via a stride trick: set that dim's nb to 0, so no matter how the index changes the address does not, and the same value is re-read, looking "copied" while taking not one extra byte. Another "change nb, don't move data" classic, of a piece with views and transpose.

Take transpose itself, laid out in full: the same row of memory, read across as the original and down as the transpose - none of the 6 values moved.

Tracing one transpose: [3,2] -> [2,3] just changes how ne/nb read this memory; the 6 underlying bytes never move.
original ne=[3,2] nb=[4,12] transposed ne=[2,3] nb=[12,4] a b c d e f a d b e c f a +0 b +4 c +8 d +12 e +16 f +20 underlying memory: 6 values never move (offset in bytes) same b: different grid position, same memory cell

A very common reshape: take activations of shape [n_embd, n_tokens] and "fan them out" for multi-head attention into [head_dim, n_head, n_tokens] - the element count is unchanged (n_embd = head_dim x n_head) and no data moves; only ne/nb are reinterpreted, turning "one big vector" into "several heads' small vectors". The multi-head attention from lesson 04 is full of these "same data, different shape" operations, all done with views and almost no extra copies.

🔬 Details / source
Or slicing: to pull out "layer k" or "head h" from a big tensor, ggml usually does not copy either - it computes a starting offset, pairs it with trimmed ne/nb, and returns a view pointing back at the original data. So in ggml code "take a part" and "see it in a different shape" are often the same cheap operation underneath - this view-centric style is a key to reading the compute-graph code later.

To spell out what "contiguous" means: a tensor is contiguous when its elements sit adjacent in memory, in ne[0], ne[1], ... order without gaps (i.e. nb follows the formula above exactly). Transpose and some slices break this: same elements, but the "walk order" no longer matches the memory layout, so ggml_is_contiguous returns false. Most operators handle contiguous tensors directly; where contiguity is required, ggml_cont copies the data into a fresh tidy block in the current shape. Remember this and you will dodge many shape-related debugging traps.

🌍 Big picture
Finally, back to op and src - they give a tensor a "dual identity": both a block of data and a node in the compute graph. When you write c = ggml_mul_mat(ctx, a, b), ggml does not compute the matmul immediately; it creates a new tensor c, records its op as "matmul" and points src[0]/src[1] at a and b. So the whole compute graph is just tensors holding hands via src into a web; once built, it is handed to the backend to compute node by node in order. Grasp this "graph node" identity of tensors and you hold the key to Part 3's ggml engine.

The struct also has a humble but handy field, name: each tensor can carry a name. This helps when debugging (spotting "which weight is this" when printing the graph), and GGUF stores each weight tensor with a name (like blk.0.attn_q.weight), matched up by name at load time. So a "name" is not just a comment - it is the index between model weights and code. The tensor names you see in GGUF tools or debug logs come straight from this field.

💡 Tip
A practical tip: the most common error writing ggml code is a shape mismatch - matmul requires the two tensors to have equal element counts on the multiplied dimension, and after transpose/reshape the dim order changes, so it is easy to misalign. Get into the habit of writing out each tensor's ne in your head (or tagging with name and printing to check) - it saves a lot of debugging time. That is why this lesson belabors ne/nb: shape is the "grammar" of ggml programming; get the grammar wrong and nothing downstream runs.

Going deeper (optional)

Three questions below; open them if you want depth, skip them if you only want the main line.

1 Why is ggml's dimension order the reverse of PyTorch? click to expand

In numpy / PyTorch, the last dimension is the contiguous one (row-major, C-order): for a tensor of shape [batch, seq, dim], dim is contiguous. ggml flips this: it puts the contiguous dimension at ne[0] (first), so the same tensor is written ne = [dim, seq, batch] - the whole order reversed.

Neither is "right"; it is just a different convention. But when reading ggml code and shapes you must mentally switch, or you will mistake rows for columns and batch for dim. A handy mnemonic: ggml's ne[0] is always the "memory-adjacent, fastest-changing" dimension.

2 Why is there a /blck_size in the nb formula? The quantized-type trap click to expand

For plain types (F32, F16), nb[0] is just the bytes of one element. But quantized types (like Q4_0) are not "one value per element"; they pack a whole block (e.g. 32 weights) into fixed-size bytes, so a single weight is not independently addressable.

So ggml uses ggml_blck_size(type) (elements per block) and ggml_type_size(type) (bytes per block). The ne[0] / ggml_blck_size(type) in the nb[1] formula means "how many blocks in a row". Once you get this, you see why a quantized tensor cannot be indexed element-by-element like a plain array - you must dequantize by block (Part 3's quantization-format lesson covers this in detail).

3 How do you compute a tensor's memory footprint? click to expand

Use ggml_nbytes(tensor) directly. Intuitively, a contiguous tensor's byte count is about "highest-dim element count x highest-dim stride" (ne[k] * nb[k] at the top dim) - i.e. multiply all dims' element counts together, times bytes per element (or per block).

This is handy for estimating memory use: how much the weights take, how much the KV cache takes, are all essentially sums of such tensor byte counts. Non-contiguous and quantized tensors compute slightly differently, but ggml_nbytes already handles those cases - just call it.

✅ Key points
  • Tensor = type + ne[4] (shape) + nb[4] (byte strides) + data (memory) + op / src (how it arose in the graph).
  • ggml is row-major, ne[0] is the innermost / contiguous dim (smallest stride); dimension order is the reverse of numpy / PyTorch.
  • Multi-dim index -> byte offset: offset = sum i_k * nb[k].
  • view / transpose / reshape are zero-copy: change ne/nb, reuse data, record origin in view_src; the cost is possible non-contiguity, compacted by ggml_cont when needed (that does copy).
  • Quantized types store by block; the /blck_size in the nb formula is exactly why.
💡 Design insight
Storing "shape" (ne/nb) and "data" separately - that single design turns transpose, slice, reshape, and broadcast all into "change a few numbers" rather than "move a whole block of memory", and lets the same weights be reused by the graph from different viewpoints. Much of ggml's efficiency hides in this plain split. In the next lesson on quantization, you will see how this ne/nb/type design lets "compress weights to 4 bits" slot quietly into the very same tensor and operator machinery.

🧪 Self-test - think about the design

1. Transposing a ggml tensor mainly changes what?
  1. Re-quantizes the weights
  2. Only swaps ne[]/nb[] (strides), reusing the same data - no data is moved
  3. Changes the type
  4. Copies out a new block of memory
Show answer & explanation click to expand
Answer: B. Transpose is a metadata-only view: swap ne/nb, point view_src back to the original, and data is untouched - hence zero-copy.
2. Which dimension of a ggml tensor is the innermost/contiguous (smallest stride) one?
  1. All the same
  2. ne[0]
  3. Decided by the type
  4. ne[3]
Show answer & explanation click to expand
Answer: B. ggml is row-major: nb[0]=ggml_type_size(type) is smallest and ne[0] is laid out contiguously - the opposite of numpy/PyTorch.
3. Why can't a quantized-type tensor (e.g. Q4_0) be indexed element-by-element like a plain array?
  1. Because it packs values by block, so a single weight is not independently addressable - you dequantize by block
  2. Because it can only be one-dimensional
  3. Because it has no ne field
  4. Because its data is empty
Show answer & explanation click to expand
Answer: A. A quantized type packs a whole block (e.g. 32 weights) into fixed bytes; the ne[0]/ggml_blck_size(type) in the nb formula is exactly 'blocks per row'.
💭 Open questions (no single right answer - just think or try)
  • For an F32 tensor with ne=[4,3] (ne[0]=4), what are nb[0] and nb[1] in bytes? (F32 = 4 bytes)