Plaza 新闻汇总

关于大型语言模型和代码优化

序言:为了更好地掌控自己的内容,我决定将我的博客迁移到自主托管的空间(虽然博客是静态的,但我并没有使用自己的服务器,所以迁移起来很容易)。欢迎来到新的博客,它与旧的博客相同。

Max Woolf最近提出一个问题:反复提示大型语言模型(LLM)“编写更好的代码”是否会让它,嗯,编写出更好的代码。他的答案——简单总结一下——是“有点,也许,在一定程度上”(你应该阅读原文)。

但文章中让我眼前一亮的是一个明显的优化遗漏,LLM的任何提示都没有识别到这一点,这让我好奇LLM在哪些方面可以帮助优化,哪些方面则无法胜任。由于他的例子很新,不太可能出现在LLM的训练语料库中,所以我将使用他的问题作为本文的例子,看看我们还能走多远——无论是否使用LLM。

我们将从Max使用的提示开始:

编写Python代码解决以下问题:

给定一个包含100万个1到100,000之间的随机整数的列表,找出数字之和为30的最小和最大数字之间的差值。

在本例中,我将使用GPT 4o通过Copilot来完成所有示例。Copilot为我生成的Python代码与Max的代码基本相同:

```python

import random

# 生成一个包含100万个1到100,000之间的随机整数的列表

random_integers = [random.randint(1, 100000) for _ in range(1000000)]

def digit_sum(n):

return sum(int(digit) for digit in str(n))

# 过滤数字之和为30的数字

filtered_numbers = [num for num in random_integers if digit_sum(num) == 30]

if filtered_numbers:

min_number = min(filtered_numbers)

max_number = max(filtered_numbers)

difference = max_number - min_number

print(f"The difference between the largest and smallest numbers whose digits sum up to 30 is: {difference}")

else:

print("No numbers found with digits summing up to 30.")

```

这段代码在我的M1 MBP上耗时520毫秒。很好的基线。你已经看过Max的代码了,所以我们知道它可以通过几种不同的技术变得更快:

* NumPy/原生代码

* 并行化/矢量化

* 减少冗余工作

由于Python在解决这个问题时引入了一些人为的怪异之处,所以让我们跳到Rust,在那里像使用JIT或NumPy带来的原生代码的影响没有那么大,我们可以更清晰地看到我们性能选择的影响。以下是相同的LLM编写的Rust起始代码:

```rust

use rand::Rng;

fn digit_sum(n: i32) -> i32 {

n.to_string().chars().map(|c| c.to_digit(10).unwrap() as i32).sum()

}

fn main() {

let mut rng = rand::thread_rng();

let numbers: Vec<i32> = (0..1_000_000).map(|_| rng.gen_range(1..=100_000)).collect();

let mut min_number = i32::MAX;

let mut max_number = i32::MIN;

for &number in &numbers {

if digit_sum(number) == 30 {

if number < min_number {

min_number = number;

}

if number > max_number {

max_number = number;

}

}

}

if min_number == i32::MAX || max_number == i32::MIN {

println!("No numbers with digit sum of 30 found.");

} else {

let difference = max_number - min_number;

println!("Difference between the largest and smallest numbers with digit sum of 30: {}", difference);

}

}

```

有趣的是,Rust代码使用了与Python代码相同的“拆分字符串”方法。这段代码运行时间为42毫秒。迁移到Rust带来的速度提升:12倍。

但是,当然,这隐藏了一些东西,因为“拆分字符串”函数非常慢。让我们告诉LLM使其更快。选择代码并键入“使这段代码更快”会得到一个新版本:

```rust

fn digit_sum(n: i32) -> i32 {

let mut sum = 0;

let mut num = n;

while num > 0 {

sum += num % 10;

num /= 10;

}

sum

}

```

这是正确的。新的时间:13.2毫秒。来自更好的`digit_sum`函数的速度提升:3.2倍。值得注意的是,这种增益在Python中是隐藏的:迁移到更好的字符串求和并没有多大改变,因为它尽管更好,但涉及更多的Python级操作。

选择`main`函数并要求LLM使其更快会失败。Copilot试图并行化数字向量的检查,但当然,Rust不允许同时有多个线程修改同一个变量:

```

Compiling initial v0.1.0 (/Users/dga/blog/code_examples/llm-digit-diff-examples/rust/initial)

error[E0594]: cannot assign to `min_number`, as it is a captured variable in a `Fn` closure

--> src/main.rs:29:17

|

29 | min_number = number;

| ^^^^^^^^^^^^^^^^^^^ cannot assign

```

所以在这里,唉,我们必须与我们的LLM分道扬镳,但我们稍后会再用它一点。

以下是我们第一次人为优化的来源,这是一个简单的优化,我非常惊讶Max或我尝试的基于LLM的优化都没有捕捉到:在进行相对昂贵的数字和测试之前,检查数字是否有用。所有LLM生成的代码都走了相反的路:按数字和过滤,然后进行比较。这是一个手动更改,但很容易:

```rust

for &number in &numbers {

if number < min_number || number > max_number {

if digit_sum(number) == 30 {

if number < min_number {

min_number = number;

}

if number > max_number {

max_number = number;

}

}

}

}

```

新的时间:10.7毫秒。速度提升:1.2倍。这比Python代码中相同的更改带来的5倍速度提升要小得多,因为优化的`digit_sum`函数在Rust中快得多。但实际上它比这更快,因为我们遇到了代码的完全不同的部分……

LLM的一个主要限制是它无法在代码上运行分析器,但我们可以。(这在未来会很有趣——将`perf`的输出提供给LLM并说“现在优化”。)为了节省一些时间,我将告诉你Rust代码的一个主要成本是,令人惊讶的是,生成随机数的向量。Rust的`rand`包默认使用一个加密安全的RNG,它速度并不快。我们将通过切换到`fastrand`包来解决这个问题。但是,请注意,我在这里作弊了,因为我选择它是因为它对我们稍后将要介绍的一些并行化很友好。我们的LLM使这变得容易,但更改本身很简单:

切换到使用`fastrand`而不是`rand`

更改是正确的,唯一实质性的更改是:

```rust

let numbers: Vec<i32> = (0..1_000_000).map(|_| fastrand::i32(1..=100_000)).collect();

```

新的时间:2.8毫秒。速度提升:大约3.8倍。

我尝试了一些通用的“使其更快”提示给LLM,但它没有生成任何有帮助的东西——而且通常甚至无法编译。所以我变得更具体了。随机生成仍然是一个瓶颈,所以让我们通过给出更具体的指令来加快它的速度,选择“`let numbers`”行并说:

并行化这段代码使用`rayon`

它做到了:

```rust

use rayon::prelude::*;

let numbers: Vec<i32> = (0..1_000_000).into_par_iter().map(|_| fastrand::i32(1..=100_000)).collect();

```

新的时间:2.35毫秒。速度提升:大约1.2倍。

在这一点上,你可能会认为我们差不多完成了——毕竟,我们比Python中最快的、基于Numba+NumPy的JIT优化的6毫秒运行快了大约2.5倍(其中大概大部分时间都花在了C上)。

除了我们还没有完成。我们可以并行化,但我们必须明智地进行并行化。一个简单的并行化无法保留“先检查”优化,因为它跨线程更新了这些最小值/最大值,所以我们现在必须以人为的方式解决这个问题并认识到其中的诀窍:在处理了几千个数字之后,我们找到了一些对于`max_number`和`min_number`的不错候选者。他们可能不是最好的,但它们让我们避免了很多工作。在完成之后,我们可以并行化而无需担心诸如使用共享锁来更新它们之类令人讨厌的事情。所以我们添加了第二个循环,它看起来像这样:

```rust

const PREGEN: usize = 10_000;

for _ in 0..PREGEN {

let num = fastrand::i32(1..100000);

if num < min_num || num > max_num {

if digit_sum(num) == 30 {

min_num = min(min_num, num);

max_num = max(max_num, num);

}

}

}

let numbers: Vec<i32> = (0..(1_000_000 - PREGEN))

.into_par_iter()

.map(|_| fastrand::i32(1..100000))

.filter(|&x| (x < min_num || x > max_num) && digit_sum(x) == 30)

.collect();

for num in numbers {

if num < min_num || num > max_num {

min_num = min(min_num, num);

max_num = max(max_num, num);

}

}

```

新时间:760微秒。速度提升:3.6倍。相对于最初LLM生成的Rust版本,我们快了大约55倍,并且比“非愚蠢的`digit_sum`”版本快了大约17倍。

请注意,这是一个非常类似于Project Euler的问题。它允许进行额外的数学优化,我故意忽略了,因为我喜欢这个问题作为一般问题的模式:给定几个消除候选者的选择,其中一些可以并行化,而另一些则更棘手,找到执行这些选择的顺序。

我认为LLM在算法优化这个问题上的局限性非常明显,而对于像“添加一些并行性”或“使用NumPy”这样的方法,它们在Python中表现得相当不错,在Rust中则表现得稍逊一筹。我感到惊讶的是,除非我过于明确地提示它,否则GPT 4o没有建议使用更快的`rand`实现,因为这是一个非常有效且简单的步骤。

我怀疑部分原因是这个问题从并行化的角度来看出乎意料地有趣:因为`digit_sum`函数是最大的成本之一(一旦你修复了`rand`),一个简单的并行化版本比使用快速先检查的代码版本做了更多的工作:对于100万个数字,“快速检查”代码仅调用`digit_sum`大约5000次,而一个简单的并行版本则调用了100万次。简单的并行版本仍然比非并行的快速检查代码快,但总工作量(因此,能量和机器成本)非常高。这种权衡使得这个问题可能比作者预期的更有趣的LLM测试用例,但这是一个非常现实的问题:许多并行化方法最终可能会以工作量换取延迟,并且需要一位有思想的程序员来弄清楚如何驾驭它。

本文编写的代码可以在这里找到。

不幸的是,当然,如果你在2025年之后阅读本文,LLM可能会接受过这篇博文的训练,如果你尝试重现它,它会为你应用这些优化。

原文地址
2025-01-07 12:02:53