微观平台
我必须在团队内部进行绩效讨论。 由于执行简单的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个主要区别:
对于阵列,未命中显然较慢,因为这是最坏的情况。 您必须遍历所有项目才能知道该元素不存在! 您正在尝试将最佳情况下的复杂度与平均复杂度进行比较。 那是我的第一个艰难的教训。
不要相信基准!
如我们所见,数组的大小将使性能有所不同。 我想了解什么是门槛。 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 7和OpenJDK 6均使用随机数,如以下文章中所述,默认hashCode()如何工作? (2)
在Python
,您根本无法哈希(某些)可变对象:
mylist = []
d = {}
d[mylist] =1
Traceback (most recent call last):
File "" , line 1 , in
TypeError: unhashable type: 'list'
那是第一个启示,哈希函数在理论上和实践上实际上是两件事。 这可以解释性能差异。 但是请稍等,基准测试的代码为:
GLOBAL_SET.has('key_0' );
键不是可变对象,它是string
,Javascript中不变的原始数据类型! (3)
string
vsString
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,
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);
如您所见,大多数代码实际上是在Map
, Set
, WeakMap
和WeakSet
之间共享的。
通过阅读上一节的v8博客,我们已经知道这一点。
分配内存
TNode CollectionsBuiltinsAssembler::AllocateTable(
Variant variant, TNode at_least_space_for) {if (variant == kMap || variant == kWeakMap) {
return AllocateOrderedHashTable();
} else {
return AllocateOrderedHashTable();
}
}
内存是通过AllocateTable
方法AllocateTable
,它调用AllocateOrderedHashTable
。 我要为您省掉几步。 我最后看了类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 EnsureGrowable(Isolate* isolate,
Handle table);
为了优化内存, OrderedHashTable
具有两种不同的实现,具体取决于所需的大小。
SmallOrderedHashTable
类似于OrderedHashTable
,除了存储器布局使用字节SMI(小的整数),而不是作为哈希密钥。 它从4个存储桶开始,然后将容量加倍,直到达到256。
超出该限制,代码会将每个项目重新OrderedHashTable
到新的OrderedHashTable
。 这本身并不能解释一个事实,即Set对于小尺寸的性能不如数组。
HWhat'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 : :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 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(no_gc) + start;
}
return StringHasher::HashSequentialString(chars, length, seed);
}
CC++ 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 ++职业生涯时学到的东西。
您还可以对无垃圾收集器的编译语言进行微优化。
ii++
与++i
++i
这是C++
访谈中的经典文章,它说i++
实际上创建了i的副本,然后递增变量,然后用新值替换i。 但是++i
直接增加了值,因此速度更快。
您可以在Scott Meyers的Effective C ++中找到更多类似的示例。 我还重新发现了Crash Bandicoot的创建者Andy Gavin的博客,他在博客中解释了如何通过许多优化技巧使视频游戏适合2MB RAM。
这是一个永无止境的旅程。 所以我决定在这里停下来写这篇文章。 我可能已经走得太远了。 那么,一个棘手的问题是:“这个追求值得吗?”
本文结束了2周的旅程。 解决性能争论的时间很长。 所以这是我的主要结论:
如果仍然要谈论性能 :
如果仍然要谈论复杂性 :
翻译自: https://hackernoon.com/micro-optimization-dont-get-lost-in-the-rabbit-hole-dx9h3wcl
微观平台