本项目作者网页:https://justine.lol/index.html

本项目github网址:https://github.com/ggerganov/llama.cpp

当 Meta 在 2 月份发布 LLaMA 时,我们中的许多人都很高兴看到高质量的大型语言模型(LLM) 可供公众访问。然而,我们中的许多人在让 LLaMA 在我们的边缘和个人计算机设备上运行时遇到了困难。一个月前, Georgi Gerganov启动了llama.cpp 项目来解决这个问题,从那时起,他的项目一直是 GitHub 上最热门的项目之一,获得了19k 星。在过去的几周里,我一直在为这个项目做志愿者,并且我有一些关于它最近进展的好消息要分享。

我们修改了 llama.cpp 以使用 mmap()而不是 C++ 标准 I/O 来加载权重。这使我们能够使用一半的内存更快地加载 LLaMA 100 倍。我们的更改刚刚在最新版本中可用。好处如下:

更多流程

您现在可以在计算机上同时运行多个 LLaMA 进程。这是 Georgi 与四个聊天机器人对话的视频,这些聊天机器人由在同一台 Mac 上运行的四个独立的 llama.cpp 进程提供支持。所以 llama.cpp 不仅会成为你更好的朋友,它也可以作为你的虚拟朋友圈。使之成为可能的技巧是mmap()让我们使用 映射只读权重MAP_SHARED,这与传统上用于加载可执行软件的技术相同。所以我们想,为什么我们不使用它来加载神经网络软件呢?现在我们可以了。

更大的模型

现在可以安全地加载 2 倍大的模型,而不会影响系统稳定性。Meta 为我们提供了 LLaMA 模型 7B、13B、30B 和 65B,其中更大的数字通常意味着更好的人工智能,对 RAM 的渴望更高。如果您之前需要 40GB 的 RAM 才能安全地加载 20GB 的模型,那么现在您需要 20GB(请注意,您的计算机还需要另外 8GB 左右的内存来存储非权重的内存)。我们的改变之所以有所改善,是因为mmap()避免了复制页面的需要。复制页面是不好的,因为您不希望复制的内存与内核文件缓存竞争。当创建过多的复制内存时,内核会通过驱逐缓存条目来做出反应,这意味着 LLaMA 每次都会从磁盘缓慢加载。自从降低内存需求以来,用户一直在讲述精彩的故事,比如 在旧的 Android 手机上运行 LLaMA-13B。对于具有 32GB RAM 的 PC,您应该能够轻松运行 LLaMA-30B,因为它是 20GB 且具有 4 位量化权重。

更快的加载

还记得每次运行命令时让您等待权重加载的进度条吗?我们摆脱了那个。Linux 用户应该期望加载时间缩短 100 倍。Windows 和 MacOS 用户应该期待 10 倍的改进。这意味着当您运行 LLaMA 时,令牌将立即开始有效地生成,几乎在 shell 上提供与 ChatGPT 类似的用户体验。重要的是要注意这些改进是由于摊销成本。重新启动计算机后第一次加载模型时,它仍然会变慢,因为它必须从磁盘加载权重。但是每次之后加载它时,它应该很快(至少在内存压力导致您的文件缓存被驱逐之前)。对于任何想使用 LLM 从 shell 脚本生成文本的人来说,这都是个好消息,类似于cat命令。但是,如果您的用例由于上下文或质量原因需要频繁重启推理,那么您现在可以更快地恢复。但是有一个问题:在您的权重文件立即加载后,您仍然需要等待提示加载。这是您可以期待很快得到解决的问题。

llama.cpp 受到如此多关注的原因之一是因为它降低了运行大型语言模型的进入门槛。这对于帮助公众更广泛地了解这些模型的好处非常有用。它还可以帮助企业节省成本。感谢 mmap()我们比以前更接近这两个目标。此外,用户可见延迟的减少使该工具使用起来更加愉快。

新的mmap()基于加载器现在在 llama.cpp 项目中可用,该项目以源代码和二进制形式在 GitHub 上根据 MIT 许可发布:

https://github.com/ggerganov/llama.cpp

现有用户需要将他们的 GGML 权重转换为新的文件格式:

less migrate-ggml-2023-03-30-pr613.py # 查看手册

python migrate-ggml-2023-03-30-pr613.py SRC DST # 运行工具

新用户应 请求访问 Meta并阅读 Simon Willison 的博客文章以了解如何开始使用。请注意,根据我们最近的更改,他的 13B 教程中与多个 .1 等文件相关的一些步骤现在可以跳过。那是因为我们的转换工具现在可以将多部分权重转换为单个文件。

我们是怎么做到的

当 llama.cpp 项目收到我们应该使用的反馈时, mmap()想到的第一个想法是找到一种方法使其在我们的 C++ 库抽象范围内工作。 @apaz-cli是推动这一进程的人。我们尝试的基本想法是看看mmap()如果我们编写一个新的std::ifstream. 这意味着,与其让底层 I/O 实现调用,不如从构造函数中read()使用,然后该函数将在后台 执行 。mmap()our_ifstream::read()memcpy()

我们确定这会将加载延迟提高 18%。这是一个大问题,因为它是用户可见的延迟。然而,事实证明我们测量的是错误的东西。请注意,我以最好的方式说“错”;犯错对知道什么是对的有重要贡献。我认为我从未见过能够做到这些的高级库mmap(),因为它拒绝抽象尝试。将我们的解决方案与动态链接器实现进行比较后,很明显 的真正价值在于mmap()根本不需要复制内存。权重只是磁盘上的一堆浮点数。在运行时,它们只是内存中的一堆浮点数。所以呢mmap() 它只是简单地使磁盘上的权重在我们想要的任何内存地址可用。我们只需确保磁盘上的布局与内存中的布局相同。

原型制作

回到绘图板后,这里棘手的是 C++ 加载过程似乎在读取张量后重塑它们。如果我们将 printf 语句添加到旧的加载代码中,我们会得到如下结果:

还有一些 C++ STL 容器在加载过程中填充了信息。很明显,为了拥有一个内存布局与运行时评估所需的相同的可映射文件,我们不仅需要创建一个新文件,还需要序列化这些 STL 数据结构。唯一的解决办法是重新设计文件格式,重写我们所有的转换工具,并要求我们的用户迁移他们的模型文件。我们已经获得了 18% 的收益,那么当我们甚至不确定新文件格式是否有效时,为什么要放弃它以取得更大的进步呢?

我最终写了一个快速而肮脏的 hack 来证明它是可行的。我使用了一个 C 库覆盖技巧,我从这样的代码开始:

然后我修改了上面的代码,避免使用堆栈或静态内存,而是依赖堆。在 Linux 等平台上,我可以通过执行以下操作轻松覆盖 libc 分配器:

C 库的妙处在于,几乎一切都依赖于它。如果你像malloc()在 Linux 这样的平台上重写函数,那么 C 下游的所有语言和工具(例如 C++)也会使用它。因此,上面的代码不仅捕获了 GGML 库的使用malloc(),还捕获了正在创建的 STL 向量和地图。我唯一需要做的就是确保堆栈分配的内存被放置在堆上,这基本上只是模型和 vocab 对象。指向这些对象的指针当然需要存储在神奇的映射区域中,以便在进程第二次加载时,它可以访问对象图的根。

这个 hack 是我如何证明加载实际上可以是瞬时的。我不需要了解太多加载器的实现细节。我刚刚重新定义了堆,使其成为一个内存映射文件,而不是它通常使用的匿名映射。请注意,上面的代码没有遵循任何最佳实践。我认为我的代码甚至值得被称为 abomination 的荣誉,这使它成为最好的实验代码。正确和正确的做法显然是改变文件格式。但这将需要 10 倍的努力。现在我们确信这样做是值得的。所以你上面看到的代码最终被扔掉了,所以我们可以专注于文件格式。

映射内存

大约一周后,我们最终放入调用该mmap()函数的主分支的第一个代码是 Slaren 的 change。这可能会让一些一直关注我的工作的人感到惊讶。经理和名人通常是获得所有荣誉的人。科技行业不习惯让其在里程碑式技术成就上的关键合作者是来自 4chan 的匿名人士,但这正是这里发生的事情。虽然带来的好处mmap()是团队的努力,但您可以说@Slaren是添加的人mmap()支持。他通过指出一些非常聪明的事情来做到这一点,即 7B 模型只有一维张量,因此不需要取消分片,因此不需要更改文件格式。所以他写了代码并更新了项目来映射文件。然后他更改了加载器,只要张量为 1-d,它就简单地分配一个指针 tensor->data而不是调用。read()通过这样做,Slaren 向我们展示了可以立即为 LLaMA 7B 用户带来即时加载时间的好处。

引入对类似功能的支持最困难的事情mmap() 是弄清楚如何让它在 Windows 上运行。如果许多过去有相同想法的人,关于使用mmap()加载机器学习模型,最终没有这样做,我不会感到惊讶,因为他们对 Windows 没有它感到气馁。事实证明,Windows 有一组几乎相同但又不完全相同的函数,称为CreateFileMapping() 和MapViewOfFile()@oKatanaaa 是帮助我们弄清楚如何使用它们来创建包装函数的最主要负责人。多亏了他,我们才能够在项目结束时删除所有旧的标准 i/o 加载程序代码,因为我们支持向量中的每个平台都能够得到mmap(). 这意味着我们实际上有一个净负面影响 C++ 代码的行数!我认为像这样的协调工作很少见,但对于保持像 llama.cpp 这样的项目的吸引力非常重要,令人惊讶的是,它仅使用几千行代码和零依赖就可以进行 LLM 推理。我们还得到了@CoderRC的一些帮助,他之前曾为Mingw32设计了自己的 POSIX 函数集 ,并且了解 mmap 特征检测的最佳技术。

更改文件格式

到目前为止,我们已经确定了mmap()对 7B 的支持。然而,对于较大的模型,我们仍然使用旧的 C++ 标准 I/O 代码。所以此时唯一要做的就是更改文件格式,以便将其mmap()推广到我们使用的所有模型。那是我负责做的部分。

为了进行推理,我们需要在转换脚本中使用 torch 从 .pth 文件中加载几百个张量。对于 7B 模型,这相对简单。我们只需要在单个文件中迭代张量,并生成单个输出文件。7B 中的张量已经很完美,并且完全连续。

$ ls -hal models/7B/
-rw-r--r-- 1 jart staff 3.9G Mar 29 17:45 ggml-model-q4_0.bin

问题是,对于大于 7B 的模型,张量被分成多个文件。按照我们以前的做事方式,当从 .pth 转换为 GGML 时,我们只是简单地进行 1:1 复制。结果,保留了从多个文件加载的丑陋之处。这是它在磁盘上的样子,例如,使用 LLaMA-65B 模型:

$ ls -hal models/65B/
-rw-r--r-- 1 jart staff 4.8G Mar 16 13:42 ggml-model-q4_0.bin
-rw-r--r-- 1 jart staff 4.8G Mar 16 13:43 ggml-model-q4_0.bin.1
-rw-r--r-- 1 jart staff 4.8G Mar 16 13:43 ggml-model-q4_0.bin.2
-rw-r--r-- 1 jart staff 4.8G Mar 16 13:44 ggml-model-q4_0.bin.3
-rw-r--r-- 1 jart staff 4.8G Mar 16 13:45 ggml-model-q4_0.bin.4
-rw-r --r-- 1 jart staff 4.8G Mar 16 13:45 ggml-model-q4_0.bin.5
-rw-r--r-- 1 jart staff 4.8G Mar 16 13:46 ggml-model-q4_0.bin .6
-rw-r--r-- 1 jart staff 4.8G Mar 16 13:46 ggml-model-q4_0.bin.7

每个文件都有相同的结构,除了张量数据本身就像隔行扫描的电影帧。

为了使事情更具挑战性,不同的张量根据名称以不同的方式分开。有些跨列拆分,有些跨行拆分。mmap()是一个强大的系统调用,但它不允许您创建适当交错张量的重叠映射。即使我们愿意使用数十万次 mmap()调用以无副本的方式重新组合读/写操作,mmap()4096 字节的对齐要求对于这种格式的张量来说也太粗糙了。我们不得不重写转换器工具,以手动将它们重新组合成一个更大的统一文件,作为前期的一次性成本。

$ ls -hal models/65B/
-rw-r--r-- 1 jart staff 38G Mar 16 13:42 ggml-model-q4_0.bin2

C++ 加载程序已经在进行必要的转换。我所要做的只是将该代码移至 Python 转换脚本中。这确保了人们以前使用的相同命令会自动使用新格式。一旦我修补了它,剩下的就是编写一个迁移脚本。这很重要,因为许多人删除了 Meta 的原始 .pth 文件以节省硬盘空间,并且他们需要一种工具将旧格式转换为新格式。这个工具就是上面推荐的脚本,叫做 migrate-ggml-2023-03-30-pr613.py. 制作起来相对简单,因为它遵循与转换工具类似的逻辑。除了在这种情况下,我不需要 Torch、pickle 或类似的东西。所需要的只是简单的旧 Numpy 与查找、读取和写入系统调用的结合。这很好,因为我最喜欢的发行版 Alpine 甚至不能运行 Torch!

该函数的有趣之处在于seek()操作系统允许我们查找文件末尾。因此,它创建了一个方便的框架,用于从多部分文件中拆分张量,因为可以通过将张量写入磁盘来执行 i/o,从而使张量具有漏洞。一旦处理完剩余的碎片,我们就可以在多个通道中填充它们。这样做当然会引发有趣的问题,即文件系统如何在底层物理介质中分配块。这不一定在我们的控制范围内,但我仍然很想了解更多。例如,在某些文件系统上,我注意到,在转换文件后,如果cp之后使用它来生成副本,它可能会更快地从磁盘加载。

新文件格式还有最后一个重要好处。它确保张量在 32 字节边界上对齐。旧文件格式在将模型词汇写入磁盘后不执行汇总。结果,花车被mmap()有一半时间是奇数地址,这会触发 UBSAN 错误。当涉及到 SIMD 指令时,它也可能会留下一些争议。在两种主要体系结构的现代微体系结构上,对齐通常不是问题。实际上,唯一完全禁止未对齐的情况是 ARM 上的信号量。然而,仅仅因为它看起来有效并不意味着错位不会在后台消耗额外的资源,或者以偷偷摸摸的方式导致其他问题。一个例子是 x86,其中未对齐的信号量似乎可以工作,直到您不幸unsigned int 重叠 64 字节缓存行边界为止。出于这个原因,新的文件格式采用了更保守的方法,

有关详细信息,请参阅78ca9838ee36660a776e97e3391b6fb5dcaacf7f 和ee0c40dd6de8c3c658ae43199939ef40bb1cf408

笔记

万维网上许多解释如何使用的信息来源mmap()也会坚持使用,madvise()就好像它的好处是既定的事实一样。我无法衡量任何证据表明它对我们的案例有帮助,因为像 LLaMA 这样的转换器模型需要在加载权重后立即对每个内存页面进行故障处理。系统madvise()调用可能仅在非常长的时间内只需要页面的子集的情况下才有用,否则在此期间磁盘将变得未充分利用。

posix_fadvise(POSIX_FADV_SEQUENTIAL)将是一种可能对 LLM 用户更有帮助的建议的示例。Linux 命令的缺点之一cp是复制大于 RAM 的文件会破坏文件缓存中的每个现有条目。在正常情况下这是一件好事,因为最近最少使用的策略通常有效。但是,如果您只是在不想破坏性能的生产系统上组织文件,则可能会出现问题。据我所知,没有标准的命令行实用程序提供利用此功能的方法。因此,我们可能会提供一个示例,说明如何替换cp也可以解决此用例的命令。此类命令可以提供的另一个功能是使用copy_file_range(),它使在同一分区内复制文件的速度比 sendfile()标准实用程序使用的技术。