对实际项目进行基准测试

  为了展示 Copilot Profiler Agent 的功能,让我们对一个广受欢迎的开源项目 CsvHelper 进行优化。您可以按照以下步骤操作:克隆我的代码仓库分支,然后通过“git checkout 435ff7c”命令切换到我修复之前的版本,我们将在下文详细介绍该修复。

  在我之前的一篇博客文章中,我添加了一个 CsvHelper.Benchmarks 项目,其中包含一个用于读取 CSV 记录的基准测试。这次我想看看我们是否可以优化 CSV 记录的写入。通常,我会通过为想要优化的代码创建基准测试来开始这项研究,不过虽然我们仍然会这样做,但我们可以让 Copilot 来承担这些繁重的工作。在 Copilot 聊天窗口中,我可以问@Profiler “帮我为 #WriteRecords 方法编写一个基准测试”。@Profiler 让我们直接与 Copilot Profiler Agent 对话,而 #WriteRecords 则明确告诉它我们要进行基准测试的方法。

1

  从这里开始,Copilot 着手创建我们的新基准测试,它会询问我们是否可以安装分析器的 NuGet 包,以便在运行基准测试时从中提取信息。它还会根据找到的任何现有基准测试来构建新的基准测试模型,因此生成的基准测试与我们已经编写的非常相似,从而保持与存储库风格的一致性。最后,它会启动构建过程,以确保一切正常。

2

  完成后,它会提供一些有用的后续提示来启动调查。我们可以点击其中一个来展开调查,不过我想对基准测试做些细微的修改。

3

  我对基准测试做了些调整,增加了几个供我们写入的字段,这里具体是 2 个整数字段和 2 个字符串字段。在为这篇博客撰写内容之前,我最初让 Copilot 来做这件事时,它每次都是写入一个新的内存流,而不是同一个内存流。写入同一个内存流或许是更好的做法,这次算你赢了 Copilot,但在我给 CsvHelper 提交的最初的拉取请求中,我并没有这么做,不过应该也没什么问题。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

public class BenchmarkWriteCsv

{

    private const int entryCount = 10000;

    private readonly List records = new(entryCount);

    public class Simple

    {

        public int Id1 { getset; }

        public int Id2 { getset; }

        public string Name1 { getset; }

        public string Name2 { getset; }

    }

    [GlobalSetup]

    public void GlobalSetup()

    {

        var random = new Random(42);

        var chars = new char[10];

        string getRandomString()

        {

            for (int i = 0; i < 10; ++i)

                chars[i] = (char)random.Next('a''z' + 1);

            return new string(chars);

        }

        for (int i = 0; i < entryCount; ++i)

        {

            records.Add(new Simple

            {

                Id1 = random.Next(),

                Id2 = random.Next(),

                Name1 = getRandomString(),

                Name2 = getRandomString(),

            });

        }

    }

    [Benchmark]

    public void WriteRecords()

    {

        using var stream = new MemoryStream();

        using var streamWriter = new StreamWriter(stream);

        using var writer = new CsvHelper.CsvWriter(streamWriter, CultureInfo.InvariantCulture);

        writer.WriteRecords(records);

        streamWriter.Flush();

    }

}

深入了解基准测试

  现在开始分析,我既可以让 Profiler Agent 运行基准测试,也可以直接点击后续提示“@Profiler Run the benchmark and analyze results”。从这里开始,Copilot 会编辑我的主方法,乍一看可能有些奇怪,但查看所做的更改后,我发现它为了使用 BenchmarkSwitcher 进行了必要的修改,这样就能选择要运行的基准测试了:

1

2

3

4

5

6

static void Main(string[] args)

{

    // Use assembly-wide discovery so all benchmarks in this assembly are run,

    // including the newly added BenchmarkWriteRecords.

    _ = BenchmarkSwitcher.FromAssembly(typeof(BenchmarkEnumerateRecords).Assembly).Run(args);

}

  然后它启动了一次基准测试运行,完成后会给我一个诊断会话,我可以在其中开始调查。

使用 Copilot Profiler Agent 来查找瓶颈

  现在到了令人兴奋的部分。运行基准测试后,Profiler Agent 会分析跟踪信息,并突出显示时间的消耗位置。我可以向 Profiler Agent 询问有关跟踪的问题,让它解释代码为什么运行缓慢,或者某些优化为何会有帮助。它已经指出,大部分时间都花在委托编译和调用上,这是针对 CSV 记录中的每个字段进行的。对于一个有 4 个字段、被写入 10,000 次的记录来说,这意味着会有 40,000 次委托调用。每次调用都有开销,而这在分析器中显示为一个热点路径。

6

  我可以问 Profiler Agent:“我怎样才能减少委托调用的开销?”或者“为什么委托调用很慢?”,而它会像一位耐心的老师一样解释相关概念并提出修复建议。

实施修复方案

  我点击 @Profiler Optimize library to produce a single compiled write delegate (reduce multicast invokes),看看会得到什么结果。 Profiler Agent 会对 ObjectRecordWriter 进行编辑,我可以在聊天窗口中点击它来查看所做更改的差异。

  查看当前的实现,代码构建了一个委托列表,每个字段对应一个委托:

1

2

3

4

5

6

7

8

9

10

11

var delegates = new List<Action>();

foreach (var memberMap in members)

{

    // ... field writing logic ...

    delegates.Add(Expression.Lambda<Action>(writeFieldMethodCall, recordParameter).Compile());

}

var action = CombineDelegates(delegates) ?? new Action((T parameter) => { });

return action;

  问题在于 CombineDelegates 会创建一个多播委托,该委托会依次单独调用每个独立的委托。相反,Profiler Agent 建议我们在编译前使用 Expression.Block 来组合所有表达式:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

var expressions = new List<Expression>(members.Count);

foreach (var memberMap in members)

{

    // ... field writing logic ...

    expressions.Add(writeFieldMethodCall);

}

if (expressions.Count == 0)

{

    return new Action<T>((T parameter) => { });

}

// Combine all field writes into a single block

var block = Expression.Block(expressions);

return Expression.Lambda<Action<T>>(block, recordParameter).Compile();

  这一改动虽小却很精妙:我们没有创建多个委托并按顺序调用它们,而是创建了一个包含所有字段写入操作的单个块表达式,然后对其进行一次编译。现在,当我们为每条记录调用委托时,所有字段都会在一次调用中完成写入,不存在额外的委托开销。

衡量影响

  做出这一更改后,Copilot 会自动重新运行基准测试以衡量改进效果。结果显示,在此次使用分析器的运行中,性能大约提升了 24%。我们之前为 CsvHelper 准备的分阶段拉取请求显示性能提升了约 15%。CPU 分析器证实,我们已经消除了委托调用的开销,对于每条有 4 个字段的 10,000 条记录,之前需要进行 40,000 次委托调用,而现在只需要 10,000 次委托调用。

9

  对于一个已经经过大量优化的库来说,这是一场意义重大的胜利。对于那些编写包含许多字段的大型 CSV 文件的应用程序而言,这一改进直接意味着 CPU 时间的减少和处理速度的提升。而且,由于 CsvHelper 的下载量高达数百万次,这项优化惠及了大量用户。在此基础上,我继续推进并提交了拉取请求,不过 Copilot 贴心地提供了更多关于类型转换和 ShouldQuote 逻辑的后续提示,以便我能进一步提升性能。

Copilot Profiler Agent 的价值

  这个工作流程之所以强大,是因为它将 Visual Studio Profiler 提供的精确性能数据与 Copilot 的分析和代码生成能力相结合。您无需手动深入研究 CPU 跟踪并试图理解热点路径的含义,而是可以提出自然语言问题,获取可执行的见解,并快速测试想法。

  该 Agent 不仅会告诉您哪些部分运行缓慢,还会帮助您理解其缓慢的原因,并提出具体的修复方法。在这种情况下,它识别出委托调用的开销是瓶颈,并建议采用 Expression.Block 优化,这正是解决该问题的正确方案。它甚至还重新运行了基准测试来确认该优化的效果!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐