微观平台_微观优化:不要迷失在兔子洞中

微观平台

我必须在团队内部进行绩效讨论。 由于执行简单的PR,我开始了2周的黑暗javascript旅程。 为了节省您很多痛苦和令人沮丧的问题,我在这篇非常长的文章中总结了我的研究。 我尽力向您展示了思路,但是如果您不关心细节,可以跳到TL; DR部分的结尾。

#冒险之旅

一切都从Github上的简单Pull Request开始。 这是一个javascript文件,我让您阅读:

综上所述,我们团队的工程师使用以下代码编写了一个单行代码,以检查该值是否有效:

['valid_value1' , 'valid_value2' ].includes(value);

另一个建议改为使用“性能改进”:

const VALID_VALUES = new Set ([ 'valid_value1' , 'valid_value2' ]);
VALID_VALUES.has(value);

争论是关于复杂性的说法,即我们正在从O(N)转向O(1)。 PR的作者开始争辩说,这不是一种改进,因为从数组创建Set的时间为O(N)。

免责声明,经过10年的发展,我对此表示了强烈的评价 就性能而言,它并不重要! 这称为微优化,它们不会对网络开发产生任何影响(对于视频游戏而言并非如此)。 因此,我利用我的经理权限停止了讨论。

#追求真理

我本可以忘掉这次谈话,然后回到我的生活。 但是我担心它会再次发生。 确实,我在职业生涯中注意到,技术团队不喜欢经理为他们做决定。 开发人员想了解原因。 因此,我不得不彻底解决这个问题,然后我开始深入研究……

作为每个程序员,我开始谷歌搜索以寻找基准。 JSPerf是一个很好的资源。 它列出了数百个基准,仅用于比较阵列和设置。 结果不是我所期望的。

两种最受欢迎​​的测试得出相反的结论。 在实验(A)中, array.includes()set.has()更快,但在实验(B)中则没有。

#基准:盟友还是敌人?

他们得出不同结论的原因是,这两个基准测试实际上并没有测试同一件事。 你能发现问题吗?

实验(A):

// SETUP
const keys = [ 'build' , 'connected' , 'firmware' , 'model' , 'status' ]
const set = new Set (keys); 
// RUN 
const randomKey = keys[ Math .floor( Math .random() * keys.length)];
keys.includes(randomKey) // --> Faster
set.has(randomKey)

实验(B):

// SETUP
var keys = [...Array( 512 )]
             .map( ( _, index ) => String .fromCharCode(index + 32 ));
var set = new Set (keys); 
// RUN 
const randomKey = String .fromCharCode(~~( Math .random() * keys.length * 2 ) + 32 );
keys.includes(randomKey)
set.has(randomKey) // --> Faster

它们是2个主要区别:

  • 实验(A)中只有5个项目,而(B)中有512个项目。
  • 实验(A)仅测试命中 (即,值在数组中时)。 作为(B)测试命中率和未命中率

对于阵列,未命中显然较慢,因为这是最坏的情况。 您必须遍历所有项目才能知道该元素不存在! 您正在尝试将最佳情况下的复杂度与平均复杂度进行比较。 那是我的第一个艰难的教训。

不要相信基准!

#统治所有人的基准?

如我们所见,数组的大小将使性能有所不同。 我想了解什么是门槛。 set何时开始变得比array更有效?

作为大多数开发人员,我认为我可以通过编写自己的代码来解决这个难题。 我专注于.has().includes()因此我自愿将Set的构造从基准中排除。

// SETUP - not included in the perf measure.
var SIZE = 1000 ;
var GLOBAL_ARRAY = [];
for ( var i = 0 ; i < SIZE; i++) {
	GLOBAL_ARRAY.push( 'key_' + i);
}
var GLOBAL_SET = new Set (GLOBAL_ARRAY);
var LAST_KEY = 'key_' + (SIZE - 1 );

var suiteHasVsIncludes = new Benchmark.Suite;
// BENCHMARK on MISS
GLOBAL_ARRAY.includes( 'key_unknown' );
GLOBAL_SET.has( 'key_unknown' );

// BENCHMARK on HIT - WORSE CASE SCENARIO
GLOBAL_ARRAY.includes(LAST_KEY);
GLOBAL_SET.has(LAST_KEY);

// BENCHMARK on HIT - BEST CASE SCENARIO
GLOBAL_ARRAY.includes( 'key_0' );
GLOBAL_SET.has( 'key_0' );

不出所料,使用Set进行查找的时间不会随Size改变太多,因为它的复杂度应为O(1)。 直到5 000,阵列中未命中的成本是线性的(由于X轴的对数刻度,很难看到)。 到目前为止,它与复杂性保持一致,因为我们必须遍历所有项O(N)。 但是,此后出现了巨大的下降。

总而言之,如果仅比较set.has()array.includes()直到SIZE为5000,则数组的执行速度是x8倍,而100000 set.has()则是x10K倍。

再一次,不要相信我自己的基准 。 我没有比其他人更好。 例如,我使用相同的密钥,如果幕后有某种缓存机制该怎么办? 我还意识到性能测试在本地计算机上运行时并不可靠。

由于您不在受控环境中,因此许多进程都在争夺CPU。 连续运行两次可能会有不同的结果(尤其是如果您在后台收听Spotify的话)。 我尝试在本地计算机上运行相同的代码10次,两次尝试之间的差异高达x3。

Try 1: array.includes() x 27,033 ops/sec ±41.13% (82 runs sampled)
Try 2: array.includes() x  9,286 ops/sec ±15.05% (83 runs sampled)

我使用的Benchmark.js库实际上告诉您要小心。 82次运行之间的差异为±41.13%。 它非常可疑,您可能应该放弃此运行。

#剩下的障碍

我首先对这个结果感到满意。 这与我对复杂性的理解保持一致。 array搜索为O(N),而set在哈希表中使用O(1)查找。

有一件事仍然困扰着我。 当SIZE <5000时, array.includes()的性能比set.has()好8倍

我无法忍受这种不连贯性,如何解释较小的Array实际上比Set更好。

#洞穴:在黑暗中更进一步

我开始在互联网上闲逛,发现这篇文章: 优化哈希表:隐藏哈希码

“ Set,Map,WeakSet和WeakMap都在后台使用哈希表。 哈希函数用于将给定键映射到哈希表中的位置。 哈希码是在给定键上运行此哈希函数的结果。

在V8中,哈希码只是一个随机数 ,与对象值无关。 因此,我们无法重新计算它,这意味着我们必须存储它。”

当我阅读最后一行时,我简直不敢相信。 在javascript中,哈希码不是哈希函数的结果,而是随机数? 为什么将其称为哈希表? 我完全迷路了。 然后我想通了。

让我们以两个玩家为例:

var player1 = {
  name : "Alice" ,
  score : 87
};
var player2 = {
  name : "Bob" ,
  score : 56
}
var set = new Set ();
set.add(player1);
set.add(player2);
player2.score = 66 ;
set.has(player2) // -> true

在Javascript中, 对象是可变的 ,例如,分数可以更改。 因此,我们不能使用对象内容来生成唯一的哈希。 如果鲍勃提高自己的分数,哈希将有所不同。 而且,由于垃圾收集器会移动对象,因此无法使用该存储位置。 这就是为什么它生成与对象一起存储的随机数的原因。 (1)

other那What about other language?
Java的实现OpenJDK 7OpenJDK 6均使用随机数,如以下文章中所述,默认hashCode()如何工作? (2)

Python ,您根本无法哈希(某些)可变对象:

mylist = []
d = {}
d[mylist] =1
Traceback (most recent call last):
      File "<stdin>" , line 1 , in <module>
    TypeError: unhashable type: 'list'

那是第一个启示,哈希函数在理论上和实践上实际上是两件事。 这可以解释性能差异。 但是请稍等,基准测试的代码为:

GLOBAL_SET.has('key_0' );

键不是可变对象,它是string ,Javascript中不变的原始数据类型! (3)

🔬 string vs String
String
不要混淆string原始和String标准的内置对象。 (4)是原始类型的包装,并且作为对象字符串是可变的。
var name = new String ( 'Alice' ); // returns a mutable object.

为什么我们不能对诸如string类的不可变键使用哈希函数? 我必须知道 我又回到了开始,所以我做了最后的决定。

在node.js中Set的实现实际上依赖于Google V8引擎。 由于它是开源的,所以我看了一下代码……

#交叉:真理在代码中

从这里我们将深入研究优化的C ++代码。 我知道这不容易阅读。 我尽了最大的努力来仅查明实现的关键部分,但请随时信任我并跳过代码示例。

首先,我在v8代码库中对Set进行了grep,最终得到了+3000个结果,所以我意识到这是一个坏主意😅。 我寻找WeakSet来缩小范围,因为它们都依赖于相同的hashmap实现。 我找到了入口点:

class BaseCollectionsAssembler : public CodeStubAssembler  {
 public:
  explicit BaseCollectionsAssembler(compiler::CodeAssemblerState* state) : CodeStubAssembler(state) {}

  virtual ~BaseCollectionsAssembler() = default ;

 protected:
  enum Variant { kMap, kSet, kWeakMap, kWeakSet };

  // Adds an entry to a collection.  For Maps, properly handles extracting the
  // key and value from the entry (see LoadKeyValue()).
  void AddConstructorEntry(Variant variant, TNode<Context> context,
                           TNode< Object > collection, TNode< Object > add_function,
                           TNode< Object > key_value,
                           Label* if_may_have_side_effects = nullptr,
                           Label* if_exception = nullptr,
                           TVariable< Object >* var_exception = nullptr);

如您所见,大多数代码实际上是在MapSetWeakMapWeakSet之间共享的。

通过阅读上一节的v8博客,我们已经知道这一点。

分配内存

TNode<HeapObject> CollectionsBuiltinsAssembler::AllocateTable(
    Variant variant, TNode<IntPtrT> at_least_space_for) {if (variant == kMap || variant == kWeakMap) {
    return AllocateOrderedHashTable<OrderedHashMap>();
  } else {
    return AllocateOrderedHashTable<OrderedHashSet>();
  }
}

内存是通过AllocateTable方法AllocateTable ,它调用AllocateOrderedHashTable<OrderedHashSet> 。 我要为您省掉几步。 我最后看了类OrderedHashTable的构造OrderedHashTable

// OrderedHashTable is a HashTable with Object keys that preserves
// insertion order. There are Map and Set interfaces (OrderedHashMap
// and OrderedHashTable, below). It is meant to be used by JSMap/JSSet.
template < class Derived , int entrysize >
class OrderedHashTable : public FixedArray {
 public :
  // Returns an OrderedHashTable (possibly |table|) with enough space
  // to add at least one new element.
  static MaybeHandle<Derived> EnsureGrowable(Isolate* isolate,
                                             Handle<Derived> table);

为了优化内存, OrderedHashTable具有两种不同的实现,具体取决于所需的大小。

SmallOrderedHashTable类似于OrderedHashTable ,除了存储器布局使用字节SMI(小的整数),而不是作为哈希密钥。 它从4个存储桶开始,然后将容量加倍,直到达到256。

超出该限制,代码会将每个项目重新OrderedHashTable到新的OrderedHashTable 。 这本身并不能解释一个事实,即Set对于小尺寸的性能不如数组。

H What's the difference between HashTable and OrderedHashTable?
What's the difference between HashTable and OrderedHashTable?
哈希表旨在为N个项目提供O(1)访问时间。 为此,他们分配了M个内存插槽,称为存储桶。 为了避免冲突,他们选择M >>N。冲突作为链接列表存储在存储桶中。
哈希表并非旨在列出所有N个元素。 为此,您将需要遍历所有M个存储桶,即使它们为空。 Javascript指定所有集合都具有.keys()方法,该方法使您可以有效地迭代键。 OrderedHashTable维护按顺序插入的另一个键列表。 (这是速度和内存之间的权衡)。
var set = new Set ([ 'key1' , 'key2' ]);
set.keys() // -> we want to iterate over the keys

寻找钥匙

我们知道Set如何存储在内存中。 下一步是看看我们如何找到钥匙。 当您调用方法set.has() ,最终将调用OrderedHashTableMethod::hasKey() ,该方法将调用OrderedHashTableMethod::FindEntry()

template < class Derived >
int SmallOrderedHashTable <Derived>: :FindEntry(Isolate* isolate, Object key) {
  DisallowHeapAllocation no_gc;
  Object hash = key->GetHash();

  if (hash->IsUndefined(isolate)) return kNotFound;
  int entry = HashToFirstEntry(Smi::ToInt(hash));

  // Walk the chain in the bucket to find the key.
  while (entry != kNotFound) {
    Object candidate_key = KeyAt(entry);
    if (candidate_key->SameValueZero(key)) return entry;
    entry = GetNextEntry(entry);
  }
  return kNotFound;
}

我们就快到了! 我们只需要了解key->GetHash()工作方式。 如果您不想阅读代码,请给我总结一下。 根据隔离的类型(对象,字符串,数组),哈希函数将有所不同。

对于对象,哈希是一个随机数(我们已经在上一部分中发现),对于string ,哈希代码是通过对每个字符进行迭代生成的。

// Object Hash returns a random number.
int Isolate::GenerateIdentityHash( uint32_t mask) {
  int hash;
  int attempts = 0 ;
  do {
    hash = random_number_generator()->NextInt() & mask;
  } while (hash == 0 && attempts++ < 30 );
  return hash != 0 ? hash : 1 ;
}
// String Hash returns hash based on iteration for each character.
template < typename Char>
uint32_t HashString(String string , size_t start, int length, uint64_t seed) {
  DisallowHeapAllocation no_gc;

  if (length > String::kMaxHashCalcLength) {
    return StringHasher::GetTrivialHash(length);
  }

  std :: unique_ptr <Char[]> buffer;
  const Char* chars;

  if ( string .IsConsString()) {
    DCHECK_EQ( 0 , start);
    DCHECK(! string .IsFlat());
    buffer.reset( new Char[length]);
    String::WriteToFlat( string , buffer.get(), 0 , length);
    chars = buffer.get();
  } else {
    chars = string .GetChars<Char>(no_gc) + start;
  }

  return StringHasher::HashSequentialString<Char>(chars, length, seed);
}
🔬C C++ Standard Library
C++ Standard Library
如果您熟悉C ++,您可能想知道为什么他们不使用HashTable的std::lib实现。 据我了解,这是因为在标准库中键入了HashTable。 您只能插入相同类型的对象。 在javascript中,他们需要一个额外的包装器才能将不同类型存储在同一集合中。 此外,在std:lib中, Set实际上实现为二进制搜索树,而不是HashTable,因此查找为O(Nlog(N))。

#关于数组的真相

现在,我们了解了Set如何在Javascript中工作,以便能够理解为什么array在较小的尺寸下表现更好,我们需要了解它是如何实现的。

在这里,我将为您节省C ++代码。 相反,我将参考V8博客中的这篇文章。

“ JavaScript对象可以具有与它们关联的任意属性。 但是,JS引擎能够优化名称纯数字的属性,最特别的是数组索引 。”

在V8中,对整数名称(数组索引)的属性的处理方式不同。 如果数值属性的外部行为与非数值属性相同,则V8选择将它们分开存储以进行优化。

在内部,V8调用数字属性: elements 。 对象的命名属性映射到值,而数组的索引映射到元素

如果我们深入研究,则有6种元素:

一方面,当阵列已满且没有Kong时,将使用PACKED_ELEMENT 。 在这种情况下,底层内存布局在C ++中进行了优化。

另一方面, HOLEY_ELEMENTS用于稀疏数组,每次在数组中创建Kong时,内存布局都会从PACKED更改为HOLEY并且永远无法返回PACKED

HOLEY_ELEMENTS的问题在于,v8无法保证它们将有返回值,并且由于原型可以被覆盖,因此v8必须沿着原型链向上检查某个地方是否有值。

PACKED_ELEMENTS我们知道该值存在,因此我们不检查允许快速访问的完整原型🚀

提醒一下,在基准测试中,我使用以下代码设置了数组:

var SIZE = 1000 ;
var GLOBAL_ARRAY = []
for ( var i = 0 ; i < SIZE; i++) {
  GLOBAL_ARRAY.push( 'key_' +  i);
}

通过这样做,我正在创建一个PACKED_ELEMENTS类型。 因此,因为v8知道我的阵列中没有Kong,所以它将优化基础内存并使用快速访问 。 如果我以不同的方式初始化数组,例如, var = GLOBAL_ARRAY = new Array( SIZE )那么我将确保创建速度更快,因为v8会预先分配合适的内存量。

但这会造成漏洞,最终我会得到HOLEY_ELEMENTS因此查找会变慢,因为我无法再使用快速访问了。

在这里,我们得出最后结论💪。 Set使用备用内存访问和HOLLEY_ELEMENTS属性,而array具有索引,这些索引映射到紧凑存储在内存中的元素,从而可以实现额外的快速访问

我终于很满意。 直到达到5000个元素为止,与V8存储阵列的内存访问改进相比,遍历整个阵列的复杂性开销可以忽略不计。

你走多远?

我的想法很冷淡:这次旅行花了我2个星期的深度研究。 我什至可以走得更远。 它使我想起了十年前我开始C ++职业生涯时学到的东西。

您还可以对无垃圾收集器的编译语言进行微优化。

🔬i i++ ++i
++i
这是C++访谈中的经典文章,它说i++实际上创建了i的副本,然后递增变量,然后用新值替换i。 但是++i直接增加了值,因此速度更快。

您可以在Scott Meyers的Effective C ++中找到更多类似的示例 我还重新发现了Crash Bandicoot的创建者Andy Gavin的博客,他在博客中解释了如何通过许多优化技巧使视频游戏适合2MB RAM。

这是一个永无止境的旅程。 所以我决定在这里停下来写这篇文章。 我可能已经走得太远了。 那么,一个棘手的问题是:“这个追求值得吗?”

🚀TL; DR与结论

本文结束了2周的旅程。 解决性能争论的时间很长。 所以这是我的主要结论:

  • 微观优化与Web开发无关。 在服务器端,瓶颈始终是对第三方的网络调用,I / O访问和数据库查询。 在前端,瓶颈将是对DOM的修改。 即使React具有虚拟DOM,渲染方法的错误实现也会破坏您的性能。
  • 专注于真正重要的事情,代码应该易于测试和易于阅读 。 在大多数情况下,微优化将使您的代码难以理解,并且难以测试。 从长远来看,拥有清晰的代码可以节省更多时间。 放眼来看,如果开发人员花了一个小时来了解微优化代码,那么每次运行可节省0.1毫秒,那么您需要进行3000万次运行才能获得正的ROI。

🏎如果仍然要谈论性能

  • 您可能永远不必在JavaScript Web应用程序中处理100 000个项目。 相反,请尝试使用分页或map-reduce以避免大数据处理。
  • 不要相信基准! 根据实现的不同,它们可能会给您带来不同的结果。 如果您有特定问题,请编写自己的基准测试,以确保对数据进行测试。
  • 使您的代码尽可能地接近问题所在。如果您决定信任某个基准,请在受控环境中运行它,例如在没有其他任何运行的专用实例的情况下。

🧲如果仍然要谈论复杂性

  • 在理论上的复杂性和数据结构(如Set)的实际实现之间存在巨大差异。 根据书籍中使用的简化描述,您可能已经有了先入为主的想法。 因为对象是可变的,并且垃圾回收器在周围移动对象,所以v8使用存储在对象上的随机数而不是对内容进行哈希处理。
  • 时间不是您要优化的唯一因素,空间和内存访问(磁盘还是RAM)之间需要权衡取舍。 V8将优化没有Kong的压缩阵列。
  • 回到源代码总是很有趣的。 它使我们想起javascript并不是一种神奇的语言,而灵活性却要付出代价。 这就是电子游戏大多使用C ++的原因。

翻译自: https://hackernoon.com/micro-optimization-dont-get-lost-in-the-rabbit-hole-dx9h3wcl

微观平台

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值