[正文内容]

上个月我发布了 ZLinq v1 版本!它通过构建在 structs 和 generics 之上,实现了零分配。它包括诸如 LINQ to Span、LINQ to SIMD、LINQ to Tree (FileSystem, JSON, GameObject, etc.) 等扩展,以及一个用于任意类型的即插即用式 Source Generator,并支持包括 .NET Standard 2.0、Unity 和 Godot 在内的多个平台。现在它在 GitHub 上的 star 数已经超过 2000。

基于 struct 的 LINQ 本身并不罕见,多年来许多实现都尝试过这种方法。然而,到目前为止,还没有一个真正实用。它们通常会遭受程序集大小膨胀、操作符覆盖不足或由于优化不足而导致的性能问题,从未发展到实验状态之外。借助 ZLinq,我们的目标是创建一个实用的东西,实现 .NET 10 中所有方法和重载的 100% 覆盖率(包括像 Shuffle、RightJoin、LeftJoin 这样的新方法),确保 99% 的行为兼容性,并实现超出减少分配的优化,包括 SIMD 支持,以便在大多数情况下表现更出色。

这得益于我在实现 LINQ 方面的丰富经验。2009 年 4 月,我发布了 linq.js,一个用于 JavaScript 的 LINQ to Objects 库(很高兴看到 linq.js 仍然由 fork 它的人维护!)。我还实现了广泛使用的 Reactive Extensions 库 UniRx for Unity,最近发布了它的进化版本 R3。我创建了诸如 LINQ to GameObjectLINQ to BigQuerySimdLinq 等变体。通过将这些经验与来自零分配相关库(ZStringZLogger)和高性能序列化器(MessagePack-CSharpMemoryPack)的知识相结合,我们实现了创建优于标准库的替代方案的宏伟目标。

这个简单的基准测试表明,当您链接更多方法(Where、Where.Take、Where.Take.Select)时,普通 LINQ 的分配会增加,而 ZLinq 保持为零。

性能因源、数量、元素类型和方法链而异。为了确认 ZLinq 在大多数情况下表现更好,我们准备了在 GitHub Actions 上运行的各种基准测试场景:ZLinq/actions/Benchmark。虽然在某些情况下 ZLinq 在结构上无法获胜,但在大多数实际场景中,它的表现都优于其他方案。

对于基准测试中的极端差异,请考虑重复多次调用 Select。System.LINQ 和 ZLinq 在这种情况下都没有应用特殊的优化,但 ZLinq 显示出显着的性能优势:

(内存测量 1B 是 BenchmarkDotNet MemoryDiagnoser 错误。文档清楚地表明 MemoryDiagnoser 的准确率为 99.5%,这意味着可能会出现轻微的测量误差。)

在简单的情况下,需要中间缓冲区(如 Distinct 或 OrderBy)的操作会显示出很大的差异,因为积极的池化显着减少了分配(ZLinq 使用了相当积极的池化,因为它主要基于 ref struct,预计它是短期的):

LINQ 应用基于方法调用模式的特殊优化,因此仅减少分配并不足以始终超越它。对于操作符链优化,例如 .NET 9 中引入的优化,如 Performance Improvements in .NET 9 中所述,ZLinq 实现了所有这些优化以实现更高的性能:

ZLinq 的一个巨大好处是,这些 LINQ 演进优化可用于所有 .NET 版本(包括 .NET Framework),而不仅仅是最新版本。

用法很简单 - 只需添加一个 AsValueEnumerable() 调用。由于所有操作符都 100% 覆盖,因此替换现有代码可以顺利进行:

using ZLinq;
var seq = source
  .AsValueEnumerable() // only add this line
  .Where(x => x % 2 == 0)
  .Select(x => x * 3);

foreach (var item in seq) { }

为确保行为兼容性,ZLinq 从 dotnet/runtime 移植了 System.Linq.Tests,并在 ZLinq/System.Linq.Tests 中持续运行它们。

9000 个测试用例保证了行为(Skip 用例是由于 ref struct 限制,无法运行相同的测试代码等)。

此外,ZLinq 提供了一个用于 Drop-In Replacement 的 Source Generator,可以选择消除对 AsValueEnumerable() 的需求:

[assembly: ZLinq.ZLinqDropInAttribute("", ZLinq.DropInGenerateTypes.Everything)]

此机制允许您自由控制 Drop-In Replacement 的范围。ZLinq/System.Linq.Tests 本身使用 Drop-In Replacement 来运行现有的测试代码,而无需更改测试。

ValueEnumerable 架构和优化

有关用法,请参阅 ReadMe。在这里,我将深入探讨优化。架构区别不仅仅是实现延迟序列执行,还包含与用其他语言编写的集合处理库相比的许多创新。

ValueEnumerable<T> 的定义构成了链式调用的基础,如下所示:

public readonly ref struct ValueEnumerable<TEnumerator, T>(TEnumerator enumerator)
  where TEnumerator : struct, IValueEnumerator<T>, allows ref struct // allows ref struct only in .NET 9 or later
{
  public readonly TEnumerator Enumerator = enumerator;
}

public interface IValueEnumerator<T> : IDisposable
{
  bool TryGetNext(out T current); // as MoveNext + Current

  // Optimization helper
  bool TryGetNonEnumeratedCount(out int count);
  bool TryGetSpan(out ReadOnlySpan<T> span);
  bool TryCopyTo(scoped Span<T> destination, Index offset);
}

基于此,像 Where 这样的操作符的链式调用如下所示:

public static ValueEnumerable<Where<TEnumerator, TSource>, TSource> Where<TEnumerator, TSource>(this ValueEnumerable<TEnumerator, TSource> source, Func<TSource, Boolean> predicate)
  where TEnumerator : struct, IValueEnumerator<TSource>, allows ref struct

我们选择这种方法而不是使用 IValueEnumerable<T>,因为如果使用像 (this TEnumerable source) where TEnumerable : struct, IValueEnumerable<TSource> 这样的定义,TSource 的类型推断将会失败。这是由于 C# 语言的限制,类型推断无法从类型参数约束中工作 (dotnet/csharplang#6930)。如果使用该定义实现,则需要为大量组合定义实例方法。LinqAF 采用了这种方法,导致了 100,000+ methods and massive assembly sizes,这并不理想。

在 LINQ 中,所有实现都在 IValueEnumerator<T> 中,并且由于所有 Enumerator 都是 structs,我意识到,我们可以简单地复制传递公共的 Enumerator,而不是使用 GetEnumerator(),允许每个 Enumerator 使用其独立的状态进行处理。这导致了用 ValueEnumerable<TEnumerator, T> 包装 IValueEnumerator<T> 的最终结构。这样,类型出现在类型声明中而不是约束中,从而避免了类型推断问题。

TryGetNext

让我们更详细地检查 MoveNext,这是迭代的核心:

// Traditional interface
public interface IEnumerator<out T> : IDisposable
{
  bool MoveNext();
  T Current { get; }
}

// iterate example
while (e.MoveNext())
{
  var item = e.Current; // invoke get_Current()
}

// ZLinq interface
public interface IValueEnumerator<T> : IDisposable
{
  bool TryGetNext(out T current);
}

// iterate example
while (e.TryGetNext(out var item)) { }

C# 的 foreach 扩展为 MoveNext() + Current,这带来了两个问题。首先,每次迭代都需要两个方法调用:MoveNext 和 get_Current。其次,Current 需要持有一个变量。因此,我将它们组合成 bool TryGetNext(out T current)。这减少了每次迭代的方法调用,从而提高了性能。

这种 bool TryGetNext(out T current) 方法也用于 Rust's iterator

pub trait Iterator {
  type Item;
  // Required method
  fn next(&mut self) -> Option<Self::Item>;
}

为了理解变量持有问题,让我们看一下 Select 的实现:

public sealed class LinqSelect<TSource, TResult>(IEnumerator<TSource> source, Func<TSource, TResult> selector) : IEnumerator<TResult>
{
  // Three fields
  IEnumerator<TSource> source = source;
  Func<TSource, TResult> selector = selector;
  TResult current = default!;
  public TResult Current => current;
  public bool MoveNext()
  {
    if (source.MoveNext())
    {
      current = selector(source.Current);
      return true;
    }
    return false;
  }
}

public ref struct ZLinqSelect<TEnumerator, TSource, TResult>(TEnumerator source, Func<TSource, TResult> selector) : IValueEnumerator<TResult>
  where TEnumerator : struct, IValueEnumerator<TSource>, allows ref struct
{
  // Two fields
  TEnumerator source = source;
  Func<TSource, TResult> selector = selector;
  public bool TryGetNext(out TResult current)
  {
    if (source.TryGetNext(out var value))
    {
      current = selector(value);
      return true;
    }
    current = default!;
    return false;
  }
}

IEnumerator<T> 需要一个 current 字段,因为它使用 MoveNext() 前进,并使用 Current 返回。但是,ZLinq 同时前进并返回值,从而消除了存储该字段的需求。这使得 ZLinq 基于 struct 的架构发生了重大变化。由于 ZLinq 采用了一种结构,其中每个方法链都完全包含前一个 struct(TEnumerator 是一个 struct),因此 struct 大小会随着每个方法链的增长而增长。虽然在合理的方法链长度内性能仍然可以接受,但较小的 struct 意味着更低的复制成本和更好的性能。采用 TryGetNext 对于最小化 struct 大小至关重要。

TryGetNext 的一个缺点是它不支持协变和逆变。但是,我认为迭代器和数组应该完全放弃协变/逆变支持。它们与 Span<T> 不兼容,这使得它们在权衡利弊时成为了过时的概念。例如,数组 Span 转换可能会在运行时失败,而没有编译时检测:

// Due to generic variance, Derived[] is accepted by Base[]
Base[] array = new Derived[] { new Derived(), new Derived() };

// In this case, casting to Span<T> or using AsSpan() causes a runtime error!
// System.ArrayTypeMismatchException: Attempted to access an element as a type incompatible with the array.
Span<Base> foo = array;

class Base;
class Derived : Base;

虽然存在此行为是因为这些功能是在 Span<T> 之前添加的,但在广泛使用 Span 的现代 .NET 中,这是有问题的,使得可能导致运行时错误的功能实际上无法使用。

TryGetNonEnumeratedCount / TryGetSpan / TryCopyTo

简单地枚举所有内容并不能最大限度地提高性能。例如,当调用 ToArray 时,如果大小没有改变(例如,array.Select().ToArray()),我们可以使用 new T[count] 创建一个固定长度的数组。System.LINQ 在内部使用 Iterator<T> 类型进行此类优化,但由于参数是 IEnumerable<T>,因此始终需要类似 if (source is Iterator<TSource> iterator) 这样的代码。

由于 ZLinq 从一开始就专门为 LINQ 设计,因此我们为这些优化做好了准备。为避免程序集大小膨胀,我们精心选择了提供最大效果的最小定义集,从而产生了这三个方法。

当原始源具有有限计数并且没有过滤方法(Where、Distinct 等,尽管 Take 和 Skip 是可计算的)干预时,TryGetNonEnumeratedCount(out int count) 成功。这有利于 ToArray 和需要中间缓冲区的方法,如 OrderBy 和 Shuffle。

当源可以作为连续内存访问时,TryGetSpan(out ReadOnlySpan<T> span) 可能会带来显着的性能改进,从而可以进行 SIMD 操作或基于 Span 的循环处理以提高聚合性能。

TryCopyTo(scoped Span<T> destination, Index offset) 通过内部迭代器增强性能。要解释外部迭代器与内部迭代器,请考虑 List<T> 同时提供 foreachForEach

// external iterator
foreach (var item in list) { Do(item); }

// internal iterator
list.ForEach(Do);

它们看起来很相似,但执行方式不同。分解实现:

// external iterator
List<T>.Enumerator e = list.GetEnumerator();
while (e.MoveNext())
{
  var item = e.Current;
  Do(item);
}

// internal iterator
for (int i = 0; i < _size; i++)
{
  action(_items[i]);
}

这变成了委托调用开销(+ 委托创建分配)与迭代器 MoveNext + Current 调用之间的竞争。内部迭代器的迭代速度本身更快。在某些情况下,委托调用可能更轻,这使得内部迭代器在基准测试中可能具有优势。

当然,这因情况而异,并且由于 lambda 捕获和正常的控制流(如 continue、break、await 等...)不可用,我个人认为不应该使用 ForEach,也不应该定义自定义扩展方法来模仿它。但是,存在这种结构差异。

TryCopyTo(scoped Span<T> destination, Index offset) 通过接受 Span 而不是委托来实现有限的内部迭代。

以 Select 为例,对于当 Count 可用时,ToArray 传递一个 Span 用于内部迭代:

public ref struct Select
{
  public bool TryCopyTo(Span<TResult> destination, Index offset)
  {
    if (source.TryGetSpan(out var span))
    {
      if (EnumeratorHelper.TryGetSlice(span, offset, destination.Length, out var slice))
      {
        // loop inlining
        for (var i = 0; i < slice.Length; i++)
        {
          destination[i] = selector(slice[i]);
        }
        return true;
      }
    }
    return false;
  }
}

// ------------------
// ToArray
if (enumerator.TryGetNonEnumeratedCount(out var count))
{
  var array = GC.AllocateUninitializedArray<TSource>(count);
  // try internal iterator
  if (enumerator.TryCopyTo(array.AsSpan(), 0))
  {
    return array;
  }

  // otherwise, use external iterator
  var i = 0;
  while (enumerator.TryGetNext(out var item))
  {
    array[i] = item;
    i++;
  }
  return array;
}

因此,虽然 Select 无法创建 Span,但如果原始源可以,则作为内部迭代器处理会加速循环处理。

TryCopyTo 与常规 CopyTo 的不同之处在于,它包含一个 Index offset 并且允许 destination 小于 source(如果 destination 较小,则正常的 .NET CopyTo 将失败)。当 destination 大小为 1 时,这使得 ElementAt 表示成为可能 - 索引 0 变为 First,^1 变为 Last。直接将 FirstLastElementAt 添加到 IValueEnumerator<T> 会在类定义中创建冗余(影响程序集大小),但将小型 destination 与 Index 结合可以使一个方法覆盖更多优化用例:

public static TSource ElementAt<TEnumerator, TSource>(this ValueEnumerable<TEnumerator, TSource> source, Index index)
  where TEnumerator : struct, IValueEnumerator<TSource>, allows ref struct
{
  using var enumerator = source.Enumerator;
  var value = default(TSource)!;
  var span = new Span<T>(ref value); // create single span
  if (enumerator.TryCopyTo(span, index))
  {
    return value;
  }

  // else...
}

LINQ to Span

在 .NET 9 及更高版本中,ZLinq 允许在 Span<T>ReadOnlySpan<T> 上链式调用所有 LINQ 操作符:

using ZLinq;

// Can also be applied to Span (only in .NET 9/C# 13 environments that support allows ref struct)
Span<int> span = stackalloc int[5] { 1, 2, 3, 4, 5 };
var seq1 = span.AsValueEnumerable().Select(x => x * x);

// If enables Drop-in replacement, you can call LINQ operator directly.
var seq2 = span.Select(x => x);

虽然有些库声称支持用于 Span 的 LINQ,但它们通常只为 Span<T> 定义扩展方法,而没有通用的机制。由于语言限制,它们提供的操作符有限,这些限制之前阻止了将 Span<T> 作为泛型参数接收。随着 .NET 9 中引入了 allows ref struct,泛型处理成为可能。

在 ZLinq 中,IEnumerable<T>Span<T> 之间没有区别 - 它们被平等对待。

但是,由于 allows ref struct 需要语言/运行时支持,因此虽然 ZLinq 支持从 .NET Standard 2.0 开始的所有 .NET 版本,但 Span 支持仅限于 .NET 9 及更高版本。这意味着在 .NET 9+ 中,所有操作符都是 ref struct,这与早期版本不同。

LINQ to SIMD

System.Linq 使用 SIMD 加速某些聚合方法。例如,直接在原始类型数组上调用 Sum 或 Max 比使用 for 循环提供更快的处理速度。但是,由于基于 IEnumerable<T>,因此适用的类型受到限制。ZLinq 通过 IValueEnumerator.TryGetSpan 使其更加通用,从而定位可以获取 Span<T> 的集合(包括直接 Span<T> 应用程序)。

支持的方法包括:

Sum 检查溢出,这增加了开销。我们添加了一个自定义 SumUnchecked 方法,该方法更快:

由于这些方法在条件匹配时隐式应用,因此有必要了解内部管道以定位 SIMD 应用程序。因此,对于 T[]Span<T>ReadOnlySpan<T>,我们提供了 .AsVectorizable() 方法来显式调用 SIMD 适用的操作,如 SumSumUncheckedAverageMaxMinContainsSequenceEqual(尽管当 Vector.IsHardwareAccelerated && Vector<T>.IsSupported 为 false 时,这些会回退到正常的处理)。

int[]Span<int> 获得了 VectorizedFillRange 方法,该方法执行与 ValueEunmerable.Range().CopyTo() 相同的操作,使用 SIMD 加速填充顺序数字。这比在需要时使用 for 循环填充要快得多:

Vectorizable 方法

手动编写 SIMD 循环处理需要实践和努力。我们提供了接受 Func 参数的助手,以方便使用。虽然这些会产生委托开销并且比内联代码执行得更差,但它们对于临时 SIMD 处理非常方便。它们接受 Func<Vector<T>, Vector<T>> vectorFuncFunc<T, T> func,在可能的情况下使用 Vector<T> 进行处理,并使用 Func<T> 处理余数。

T[]Span<T> 提供了 VectorizedUpdate 方法:

using ZLinq.Simd; // needs using

int[] source = Enumerable.Range(0, 10000).ToArray();

[Benchmark]
public void For()
{
  for (int i = 0; i < source.Length; i++)
  {
    source[i] = source[i] * 10;
  }
}

[Benchmark]
public void VectorizedUpdate()
{
  // arg1: Vector<int> => Vector<int>
  // arg2: int => int
  source.VectorizedUpdate(static x => x * 10, static x => x * 10);
}

虽然比 for 循环快,但性能因机器环境和大小而异,因此建议为每个用例进行验证。

AsVectorizable() 提供 AggregateAllAnyCountSelectZip

source.AsVectorizable().Aggregate((x, y) => Vector.Min(x, y), (x, y) => Math.Min(x, y));
source.AsVectorizable().All(x => Vector.GreaterThanAll(x, new(5000)), x => x > 5000);
source.AsVectorizable().Any(x => Vector.LessThanAll(x, new(5000)), x => x < 5000);
source.AsVectorizable().Count(x => Vector.GreaterThan(x, new(5000)), x => x > 5000);

性能取决于数据,但 Count 可以显示出显着的差异:

对于 SelectZip,您可以随后使用 ToArrayCopyTo

// Select
source.AsVectorizable().Select(x => x * 3, x => x * 3).ToArray();
source.AsVectorizable().Select(x => x * 3, x => x * 3).CopyTo(destination);

// Zip2
array1.AsVectorizable().Zip(array2, (x, y) => x + y, (x, y) => x + y).CopyTo(destination);
array1.AsVectorizable().Zip(array2, (x, y) => x + y, (x, y) => x + y).ToArray();

// Zip3
array1.AsVectorizable().Zip(array2, array3, (x, y, z) => x + y + z, (x, y, z) => x + y + z).CopyTo(destination);
array1.AsVectorizable().Zip(array2, array3, (x, y, z) => x + y + z, (x, y, z) => x + y + z).ToArray();

Zip 对于某些用例(如合并两个 Vec3)可能特别有趣且快速:

LINQ to Tree

你使用过 LINQ to XML 吗?在 2008 年 LINQ 出现时,XML 仍然占主导地位,LINQ to XML 的可用性令人震惊。现在 JSON 已经接管了,LINQ to XML 很少使用。

但是,LINQ to XML 的价值在于它是树结构上 LINQ 风格操作的参考设计——是使树结构与 LINQ 兼容的指南。树遍历抽象与 LINQ to Objects 配合得非常好。一个典型的例子是使用 Roslyn 的 SyntaxTree,其中诸如 Descendants 之类的方法通常用于 Analyzers 和 Source Generators 中。

ZLinq 通过定义一个接口来扩展这个概念,该接口为树结构通用地启用 AncestorsChildrenDescendantsBeforeSelfAfterSelf

此图显示了 Unity 的 GameObject 的遍历,但我们包含了 FileSystem (DirectoryTree) 和 JSON 的标准实现(在 System.Text.Json 的 JsonNode 上启用 LINQ to XML 风格的操作)。当然,您可以为自定义类型实现该接口:

public interface ITraverser<TTraverser, T> : IDisposable
  where TTraverser : struct, ITraverser<TTraverser, T> // self
{
  T Origin { get; }
  TTraverser ConvertToTraverser(T next); // for Descendants
  bool TryGetHasChild(out bool hasChild); // optional: optimize use for Descendants
  bool TryGetChildCount(out int count); // optional: optimize use for Children
  bool TryGetParent(out T parent); // for Ancestors
  bool TryGetNextChild(out T child); // for Children | Descendants
  bool TryGetNextSibling(out T next); // for AfterSelf
  bool TryGetPreviousSibling(out T previous); // BeforeSelf
}

对于 JSON,您可以编写:

var json = JsonNode.Parse("""// snip...""");

// JsonNode
var origin = json!["nesting"]!["level1"]!["level2"]!;

// JsonNode axis, Children, Descendants, Anestors, BeforeSelf, AfterSelf and ***Self.
foreach (var item in origin.Descendants().Select(x => x.Node).OfType<JsonArray>())
{
  // [true, false, true], ["fast", "accurate", "balanced"], [1, 1, 2, 3, 5, 8, 13]
  Console.WriteLine(item.ToJsonString(JsonSerializerOptions.Web));
}

我们包含了 Unity 的 GameObjectTransform 以及 Godot 的 Node 的标准 LINQ to Tree 实现。由于分配和遍历性能都经过精心优化,因此它们甚至可能比手动循环更快。

OSS 和我

近几个月来,.NET 相关的 OSS 中发生了一些事件,包括知名 OSS 项目的商业化。在 github/Cysharp 下有 40 多个 OSS 项目,在我的个人和其他组织(如 MessagePack)下有更多项目,总计超过 50,000 个 star,我相信我是 .NET 生态系统中最大的 OSS 提供商之一。

关于商业化,我没有计划这样做,但由于规模不断扩大,维护已变得具有挑战性。尽管受到批评,但 OSS 项目尝试商业化的一个主要因素是维护人员的精神负担(报酬与时间投入不符)。我也经历过这种情况!

抛开财务方面不谈,我的请求是用户接受偶尔的维护延迟!在开发像 ZLinq 这样的大型库时,我需要集中的时间,这意味着其他库的 Issues 和 PR 可能会几个月没有响应。我故意避免查看它们,甚至不阅读标题(避免仪表板和通知电子邮件)。这种看似疏忽的方法对于创建创新库是必要的——一种必要的牺牲!

即使没有那样,库的庞大数量也意味着几个月的轮换延迟是不可避免的。由于绝对的人力短缺,这是不可避免的,所以请接受这些延迟,不要因为响应缓慢而声称“这个库已经死了”。听到这些很痛苦!我尽力而为,但创建新库会消耗大量时间,导致级联延迟,从而耗尽我的精神能量。

此外,与 Microsoft 相关的烦恼会降低积极性——这是 C# OSS 维护人员的常见经历。尽管如此,我希望继续长期发展。

结论

在收到最初预览版的反馈后,ZLinq 的结构发生了显着变化。@Akeit0 为核心性能关键元素(如 ValueEnumerable<TEnumerator, T> 定义以及将 Index 添加到 TryCopyTo)提供了许多建议。@filzrev 贡献了广泛的测试和基准测试基础设施。如果没有他们的贡献,就不可能确保兼容性和性能改进,对此我深表感谢。

虽然零分配 LINQ 库并不新鲜,但 ZLinq 的彻底性使其与众不同。凭借经验和知识,在纯粹的决心的驱动下,我们实现了所有方法,运行了所有测试用例以实现完全兼容性,并实现了包括 SIMD 在内的所有优化。这真的很有挑战性!

时机非常完美,因为 .NET 9/C# 13 提供了完整实现所需的所有语言功能。同时,保持对 Unity 和 .NET Standard 2.0 的支持也很重要。

除了仅仅是一个零分配 LINQ 之外,LINQ to Tree 是我希望人们尝试的最喜欢的功能!

一个 LINQ 性能瓶颈是 delegates,一些库采用使用 structs 模拟 Func 的 ValueDelegate 方法。我们故意避免了这一点,因为由于其复杂性,此类定义是不切实际的。使用具有 ValueDelegate 结构的 LINQ 编写内联代码比这更好。为了基准测试黑客而使内部结构复杂化并膨胀程序集大小是浪费的,因此我们只接受 System.Linq 兼容。

R3 是一个雄心勃勃的库,旨在取代 .NET 的标准 System.Reactive,但替换 System.Linq 将是一项更大甚至过度的任务,因此我认为可能会有一些抵制采用。但是,我相信我们已经证明了足够的优势来证明替换的合理性,因此如果您能尝试一下,我将非常高兴!