为什么地图的查找速度 属性 比 Erlang 中的记录慢?
Why do Maps have slower lookup property than Records in Erlang?
我在读 Programming Erlang,在书的第 5 章中说:
Records are just tuples in disguise, so they have the same storage and performance
characteristics as tuples. Maps use more storage than tuples and have
slower lookup properties.
我以前学过的语言不是这样的。 maps通常实现为一个HashTable,所以查找时间复杂度为O(1)
; Records(带名字的元组)通常实现为不可变的List,查找时间复杂度为O(N)
.
Erlang 中这些数据结构的实现有何不同?
对于少量字段,记录查找和地图查找之间没有真正的实际性能差异。但是,对于大量字段,因为记录信息在编译时已知而映射键不需要,所以映射使用与记录不同的查找机制。但是记录和地图并不打算作为可互换的替代品,因此在 IMO 中,将它们与涉及少量字段的用例进行比较是毫无意义的;如果您在编译时知道所需的字段,请使用记录,但如果不知道,请使用映射或其他类似机制。正因为如此,下面只关注查找一个记录字段和一个映射键的性能差异。
让我们来看看两个函数的汇编程序,一个访问记录字段,一个访问映射键。以下是函数:
-record(foo, {f}).
r(#foo{f=X}) ->
X.
m(#{f := X}) ->
X.
两者都使用模式匹配从给定的类型实例中提取值。
这是 r/1
的程序集:
{function, r, 1, 2}.
{label,1}.
{line,[{location,"f2.erl",6}]}.
{func_info,{atom,f2},{atom,r},1}.
{label,2}.
{test,is_tuple,{f,1},[{x,0}]}.
{test,test_arity,{f,1},[{x,0},2]}.
{get_tuple_element,{x,0},0,{x,1}}.
{get_tuple_element,{x,0},1,{x,2}}.
{test,is_eq_exact,{f,1},[{x,1},{atom,foo}]}.
{move,{x,2},{x,0}}.
return.
这里有趣的部分从 {label,2}
开始。代码验证参数是一个元组,然后验证元组的元数,并从中提取两个元素。在验证元组的第一个元素等于原子 foo
后,它 returns 第二个元素的值,即记录字段 f
.
现在让我们看一下m/1
函数的汇编:
{function, m, 1, 4}.
{label,3}.
{line,[{location,"f2.erl",9}]}.
{func_info,{atom,f2},{atom,m},1}.
{label,4}.
{test,is_map,{f,3},[{x,0}]}.
{get_map_elements,{f,3},{x,0},{list,[{atom,f},{x,0}]}}.
return.
此代码验证参数是一个映射,然后提取与映射键关联的值 f
。
每个功能的成本归结为汇编指令的成本。 record 函数有更多指令,但它们可能比 map 函数中的指令更便宜,因为所有记录信息在编译时都是已知的。随着地图的键数增加,这一点尤其正确,因为这意味着 get_map_elements
调用可能必须遍历更多地图数据才能找到它要查找的内容。
我们可以编写函数来多次调用这些访问器,然后为新函数计时。这是调用访问器 N
次的两组递归函数:
call_r(N) ->
call_r(#foo{f=1},N).
call_r(_,0) ->
ok;
call_r(F,N) ->
1 = r(F),
call_r(F,N-1).
call_m(N) ->
call_m(#{f => 1},N).
call_m(_,0) ->
ok;
call_m(M,N) ->
1 = m(M),
call_m(M,N-1).
我们可以用timer:tc/3
调用它们来检查每个函数的执行时间。让我们调用每个一千万次,但调用 50 次并取平均执行时间。一、记录功能:
1> lists:sum([element(1,timer:tc(f2,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
237559.02
这意味着调用该函数一千万次平均耗时 238 毫秒。现在,地图功能:
2> lists:sum([element(1,timer:tc(f2,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
235871.94
调用地图函数一千万次平均每次调用 236 毫秒。当然,你的里程会有所不同,我的也是。我观察到 运行 每次多次有时会导致 record 函数更快,有时 map 函数会更快,但两者都没有大大加快。我鼓励您进行自己的测量,但似乎两者之间几乎没有性能差异,至少对于通过模式匹配访问单个字段而言是这样。但是,随着字段数量的增加,性能差异变得更加明显:对于 10 个字段,映射速度慢约 0.5%,而对于 50 个字段,映射速度慢约 50%。但正如我前面所说,我认为这无关紧要,因为如果你试图交替使用记录和地图,那你就错了。
更新:根据评论中的对话,我澄清了随着 fields/keys 数量的增加讨论性能差异的答案,并指出记录和地图并不意味着可以互换。
更新:对于 Erlang/OTP 24 的 Erlang Efficiency Guide was augmented with a chapter on maps 值得一读以获得此问题的详细答案。
用 Erlang/OTP 22 [erts-10.6] 重复测试时我得到了不同的结果。
r/1的反汇编代码不同:
记录查找速度提高 1.5 倍以上。
{function, r, 1, 2}.
{label,1}.
{line,[{location,"f2.erl",9}]}.
{func_info,{atom,f2},{atom,r},1}.
{label,2}.
{test,is_tagged_tuple,{f,1},[{x,0},2,{atom,foo}]}.
{get_tuple_element,{x,0},1,{x,0}}.
return.
{function, m, 1, 4}.
{label,3}.
{line,[{location,"f2.erl",12}]}.
{func_info,{atom,f2},{atom,m},1}.
{label,4}.
{test,is_map,{f,3},[{x,0}]}.
{get_map_elements,{f,3},{x,0},{list,[{atom,f},{x,1}]}}.
{move,{x,1},{x,0}}.
return.
9> lists:sum([element(1,timer:tc(f2,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
234309.04
10> lists:sum([element(1,timer:tc(f2,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
341411.9
After I declared -compile({inline, [r/1, m/1]}).
13> lists:sum([element(1,timer:tc(f2,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
199978.9
14> lists:sum([element(1,timer:tc(f2,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
356002.48
我将具有 10 个元素的记录与相同大小的地图进行了比较。在这种情况下,记录被证明快了 2 倍多。
-module(f22).
-compile({inline, [r/1, m/1]}).
-export([call_r/1, call_r/2, call_m/1, call_m/2]).
-define(I, '2').
-define(V, 2 ).
-record(foo, {
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'0'
}).
r(#foo{?I = X}) ->
X.
m(#{?I := X}) ->
X.
call_r(N) ->
call_r(#foo{
'1' = 1,
'2' = 2,
'3' = 3,
'4' = 4,
'5' = 5,
'6' = 6,
'7' = 7,
'8' = 8,
'9' = 9,
'0' = 0
}, N).
call_r(_,0) ->
ok;
call_r(F,N) ->
?V = r(F),
call_r(F,N-1).
call_m(N) ->
call_m(#{
'1' => 1,
'2' => 2,
'3' => 3,
'4' => 4,
'5' => 5,
'6' => 6,
'7' => 7,
'8' => 8,
'9' => 9,
'0' => 0
}, N).
call_m(_,0) ->
ok;
call_m(F,N) ->
?V = m(F),
call_m(F,N-1).
% lists:sum([element(1,timer:tc(f22,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
% 229777.3
% lists:sum([element(1,timer:tc(f22,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
% 395897.68
% After declaring
% -compile({inline, [r/1, m/1]}).
% lists:sum([element(1,timer:tc(f22,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
% 130859.98
% lists:sum([element(1,timer:tc(f22,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
% 306490.6
% 306490.6 / 130859.98 .
% 2.34212629407401
我在读 Programming Erlang,在书的第 5 章中说:
Records are just tuples in disguise, so they have the same storage and performance characteristics as tuples. Maps use more storage than tuples and have slower lookup properties.
我以前学过的语言不是这样的。 maps通常实现为一个HashTable,所以查找时间复杂度为O(1)
; Records(带名字的元组)通常实现为不可变的List,查找时间复杂度为O(N)
.
Erlang 中这些数据结构的实现有何不同?
对于少量字段,记录查找和地图查找之间没有真正的实际性能差异。但是,对于大量字段,因为记录信息在编译时已知而映射键不需要,所以映射使用与记录不同的查找机制。但是记录和地图并不打算作为可互换的替代品,因此在 IMO 中,将它们与涉及少量字段的用例进行比较是毫无意义的;如果您在编译时知道所需的字段,请使用记录,但如果不知道,请使用映射或其他类似机制。正因为如此,下面只关注查找一个记录字段和一个映射键的性能差异。
让我们来看看两个函数的汇编程序,一个访问记录字段,一个访问映射键。以下是函数:
-record(foo, {f}).
r(#foo{f=X}) ->
X.
m(#{f := X}) ->
X.
两者都使用模式匹配从给定的类型实例中提取值。
这是 r/1
的程序集:
{function, r, 1, 2}.
{label,1}.
{line,[{location,"f2.erl",6}]}.
{func_info,{atom,f2},{atom,r},1}.
{label,2}.
{test,is_tuple,{f,1},[{x,0}]}.
{test,test_arity,{f,1},[{x,0},2]}.
{get_tuple_element,{x,0},0,{x,1}}.
{get_tuple_element,{x,0},1,{x,2}}.
{test,is_eq_exact,{f,1},[{x,1},{atom,foo}]}.
{move,{x,2},{x,0}}.
return.
这里有趣的部分从 {label,2}
开始。代码验证参数是一个元组,然后验证元组的元数,并从中提取两个元素。在验证元组的第一个元素等于原子 foo
后,它 returns 第二个元素的值,即记录字段 f
.
现在让我们看一下m/1
函数的汇编:
{function, m, 1, 4}.
{label,3}.
{line,[{location,"f2.erl",9}]}.
{func_info,{atom,f2},{atom,m},1}.
{label,4}.
{test,is_map,{f,3},[{x,0}]}.
{get_map_elements,{f,3},{x,0},{list,[{atom,f},{x,0}]}}.
return.
此代码验证参数是一个映射,然后提取与映射键关联的值 f
。
每个功能的成本归结为汇编指令的成本。 record 函数有更多指令,但它们可能比 map 函数中的指令更便宜,因为所有记录信息在编译时都是已知的。随着地图的键数增加,这一点尤其正确,因为这意味着 get_map_elements
调用可能必须遍历更多地图数据才能找到它要查找的内容。
我们可以编写函数来多次调用这些访问器,然后为新函数计时。这是调用访问器 N
次的两组递归函数:
call_r(N) ->
call_r(#foo{f=1},N).
call_r(_,0) ->
ok;
call_r(F,N) ->
1 = r(F),
call_r(F,N-1).
call_m(N) ->
call_m(#{f => 1},N).
call_m(_,0) ->
ok;
call_m(M,N) ->
1 = m(M),
call_m(M,N-1).
我们可以用timer:tc/3
调用它们来检查每个函数的执行时间。让我们调用每个一千万次,但调用 50 次并取平均执行时间。一、记录功能:
1> lists:sum([element(1,timer:tc(f2,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
237559.02
这意味着调用该函数一千万次平均耗时 238 毫秒。现在,地图功能:
2> lists:sum([element(1,timer:tc(f2,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
235871.94
调用地图函数一千万次平均每次调用 236 毫秒。当然,你的里程会有所不同,我的也是。我观察到 运行 每次多次有时会导致 record 函数更快,有时 map 函数会更快,但两者都没有大大加快。我鼓励您进行自己的测量,但似乎两者之间几乎没有性能差异,至少对于通过模式匹配访问单个字段而言是这样。但是,随着字段数量的增加,性能差异变得更加明显:对于 10 个字段,映射速度慢约 0.5%,而对于 50 个字段,映射速度慢约 50%。但正如我前面所说,我认为这无关紧要,因为如果你试图交替使用记录和地图,那你就错了。
更新:根据评论中的对话,我澄清了随着 fields/keys 数量的增加讨论性能差异的答案,并指出记录和地图并不意味着可以互换。
更新:对于 Erlang/OTP 24 的 Erlang Efficiency Guide was augmented with a chapter on maps 值得一读以获得此问题的详细答案。
用 Erlang/OTP 22 [erts-10.6] 重复测试时我得到了不同的结果。
r/1的反汇编代码不同:
记录查找速度提高 1.5 倍以上。
{function, r, 1, 2}.
{label,1}.
{line,[{location,"f2.erl",9}]}.
{func_info,{atom,f2},{atom,r},1}.
{label,2}.
{test,is_tagged_tuple,{f,1},[{x,0},2,{atom,foo}]}.
{get_tuple_element,{x,0},1,{x,0}}.
return.
{function, m, 1, 4}.
{label,3}.
{line,[{location,"f2.erl",12}]}.
{func_info,{atom,f2},{atom,m},1}.
{label,4}.
{test,is_map,{f,3},[{x,0}]}.
{get_map_elements,{f,3},{x,0},{list,[{atom,f},{x,1}]}}.
{move,{x,1},{x,0}}.
return.
9> lists:sum([element(1,timer:tc(f2,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
234309.04
10> lists:sum([element(1,timer:tc(f2,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
341411.9
After I declared -compile({inline, [r/1, m/1]}).
13> lists:sum([element(1,timer:tc(f2,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
199978.9
14> lists:sum([element(1,timer:tc(f2,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
356002.48
我将具有 10 个元素的记录与相同大小的地图进行了比较。在这种情况下,记录被证明快了 2 倍多。
-module(f22).
-compile({inline, [r/1, m/1]}).
-export([call_r/1, call_r/2, call_m/1, call_m/2]).
-define(I, '2').
-define(V, 2 ).
-record(foo, {
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'0'
}).
r(#foo{?I = X}) ->
X.
m(#{?I := X}) ->
X.
call_r(N) ->
call_r(#foo{
'1' = 1,
'2' = 2,
'3' = 3,
'4' = 4,
'5' = 5,
'6' = 6,
'7' = 7,
'8' = 8,
'9' = 9,
'0' = 0
}, N).
call_r(_,0) ->
ok;
call_r(F,N) ->
?V = r(F),
call_r(F,N-1).
call_m(N) ->
call_m(#{
'1' => 1,
'2' => 2,
'3' => 3,
'4' => 4,
'5' => 5,
'6' => 6,
'7' => 7,
'8' => 8,
'9' => 9,
'0' => 0
}, N).
call_m(_,0) ->
ok;
call_m(F,N) ->
?V = m(F),
call_m(F,N-1).
% lists:sum([element(1,timer:tc(f22,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
% 229777.3
% lists:sum([element(1,timer:tc(f22,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
% 395897.68
% After declaring
% -compile({inline, [r/1, m/1]}).
% lists:sum([element(1,timer:tc(f22,call_r,[10000000])) || _ <- lists:seq(1,50)])/50.
% 130859.98
% lists:sum([element(1,timer:tc(f22,call_m,[10000000])) || _ <- lists:seq(1,50)])/50.
% 306490.6
% 306490.6 / 130859.98 .
% 2.34212629407401