【问题标题】:Execution time with process.hrtime() return vastly different result使用 process.hrtime() 的执行时间返回截然不同的结果
【发布时间】:2017-09-18 02:55:06
【问题描述】:

我无法解释为什么我的性能测试在 2 种不同类型的运行中返回显着不同的结果。

重现问题的步骤:

  1. 从 gist 获取代码: https://gist.github.com/AVAVT/83685bfe5280efc7278465f90657b9ea
  2. 运行node practice1.generator
  3. 运行node practice1.performance-test

practice1.generator 应该生成一个test-data.json 文件,并将一些搜索算法执行时间记录到控制台中。 之后,practice1.performance-test 从test-data.json 中读取数据,并对相同的数据执行完全相同的评估函数。

我机器上的输出始终与此类似:

> node practice1.generator
Generate time: 9,307,061,368 nanoseconds
Total time using indexOf             : 7,005,750 nanoseconds
Total time using for loop            : 7,463,967 nanoseconds
Total time using binary search       : 1,741,822 nanoseconds
Total time using interpolation search: 915,532 nanoseconds

> node practice1.performance-test
Total time using indexOf             : 11,574,993 nanoseconds
Total time using for loop            : 8,765,902 nanoseconds
Total time using binary search       : 2,365,598 nanoseconds
Total time using interpolation search: 771,005 nanoseconds

注意indexOf 和binary search 与其他算法相比的执行时间差异。

如果我反复运行node practice1.generator 或 node practice1.performance-test,结果还是相当一致的。

现在这太令人不安了,我无法找到一种方法来确定哪个结果是可信的,以及为什么会出现这种差异。是否是由于生成的测试数组与 JSON.parse-d 测试数组之间的差异造成的;还是process.hrtime()引起的;还是我什至无法理解的未知原因?


更新:我已经追踪到indexOf案例的原因是因为JSON.parse。在practice1.generator内部,tests数组是原始生成的数组;而在practice1.performance-test 中,数组是从 json 文件中读取的,并且可能与原始数组有所不同。

如果在practice1.generator 内我改为JSON.parse() 来自字符串的一个新数组:

var tests2 = JSON.parse(JSON.stringify(tests));

performanceUtil.performanceTest(tests2);

indexOf 的执行时间现在在两个文件上是一致的。

> node practice1.generator
Generate time: 9,026,080,466 nanoseconds
Total time using indexOf             : 11,016,420 nanoseconds
Total time using for loop            : 8,534,540 nanoseconds
Total time using binary search       : 1,586,780 nanoseconds
Total time using interpolation search: 742,460 nanoseconds

> node practice1.performance-test
Total time using indexOf             : 11,423,556 nanoseconds
Total time using for loop            : 8,509,602 nanoseconds
Total time using binary search       : 2,303,099 nanoseconds
Total time using interpolation search: 718,723 nanoseconds

所以至少我知道indexOf 在原始数组上运行得更好,而在JSON.parse-d 数组上运行得更差。 我还是只知道原因,不知道为什么。

2 个文件的二进制搜索执行时间仍然不同,在 practice1.generator 中始终花费 ~1.7ms(即使使用 JSON.parse-d 对象)和在 practice1.performance-test 中花费 ~2.3ms .


以下是与要点中相同的代码,供将来参考。

performance-utils.js:

'use strict';

const performanceTest = function(tests){
  var tindexOf = process.hrtime();
  tests.forEach(testcase => {
    var result = testcase.input.indexOf(testcase.target);

    if(result !== testcase.output) console.log("Errr", result, testcase.output);
  });
  tindexOf = process.hrtime(tindexOf);

  var tmanual = process.hrtime();
  tests.forEach(testcase => {
    const arrLen = testcase.input.length;
    var result = -1;
    for(var i=0;i<arrLen;i++){
      if(testcase.input[i] === testcase.target){
        result = i;
        break;
      }
    }

    if(result !== testcase.output) console.log("Errr", result, testcase.output);
  });
  tmanual = process.hrtime(tmanual);

  var tbinary = process.hrtime();
  tests.forEach(testcase => {
    var max = testcase.input.length-1;
    var min = 0;
    var check, num;
    var result = -1;

    while(max => min){
      check = Math.floor((max+min)/2);
      num = testcase.input[check];

      if(num === testcase.target){
        result = check;
        break;
      }
      else if(num > testcase.target) max = check-1;
      else min = check+1;
    }

    if(result !== testcase.output) console.log("Errr", result, testcase.output);
  });
  tbinary = process.hrtime(tbinary);


  var tinterpolation = process.hrtime();
  tests.forEach(testcase => {
    var max = testcase.input.length-1;
    var min = 0;
    var result = -1;
    var check, num;

    while(max > min && testcase.target >= testcase.input[min] && testcase.target <= testcase.input[max]){
      check = min +  Math.round((max-min) * (testcase.target - testcase.input[min]) / (testcase.input[max]-testcase.input[min]));
      num = testcase.input[check];

      if(num === testcase.target){
        result = check;
        break;
      }
      else if(testcase.target > num) min = check + 1;
      else max = check - 1;
    }

    if(result === -1 && testcase.input[max] == testcase.target) result = max;

    if(result !== testcase.output) console.log("Errr", result, testcase.output);
  });
  tinterpolation = process.hrtime(tinterpolation);

  console.log(`Total time using indexOf             : ${(tindexOf[0] * 1e9 + tindexOf[1]).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")} nanoseconds`);
  console.log(`Total time using for loop            : ${(tmanual[0] * 1e9 + tmanual[1]).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")} nanoseconds`);
  console.log(`Total time using binary search       : ${(tbinary[0] * 1e9 + tbinary[1]).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")} nanoseconds`);
  console.log(`Total time using interpolation search: ${(tinterpolation[0] * 1e9 + tinterpolation[1]).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")} nanoseconds`);
}

module.exports = { performanceTest }

practice1.generator.js:

'use strict';

require('util');
const performanceUtil = require('./performance-utils');
const fs = require('fs');
const path = require('path');
const outputFilePath = path.join(__dirname, process.argv[3] || 'test-data.json');

const AMOUNT_TO_GENERATE = parseInt(process.argv[2] || 1000);

// Make sure ARRAY_LENGTH_MAX < (MAX_NUMBER - MIN_NUMBER)
const ARRAY_LENGTH_MIN = 10000;
const ARRAY_LENGTH_MAX = 18000;
const MIN_NUMBER = -10000;
const MAX_NUMBER = 10000;

const candidates = Array.from(Array(MAX_NUMBER - MIN_NUMBER + 1), (item, index) => MIN_NUMBER + index);

function createNewTestcase(){
  var input = candidates.slice();
  const lengthToGenerate = Math.floor(Math.random()*(ARRAY_LENGTH_MAX - ARRAY_LENGTH_MIN + 1)) + ARRAY_LENGTH_MIN;

  while(input.length > lengthToGenerate){
    input.splice(Math.floor(Math.random()*input.length), 1);
  }

  const notfound = input.length === lengthToGenerate ?
    input.splice(Math.floor(Math.random()*input.length), 1)[0] : MIN_NUMBER-1;

  const output = Math.floor(Math.random()*(input.length+1)) - 1;
  const target = output === -1 ? notfound : input[output];

  return {
    input,
    target,
    output
  };
}

var tgen = process.hrtime();

var tests = [];
while(tests.length < AMOUNT_TO_GENERATE){
  tests.push(createNewTestcase());
}

fs.writeFileSync(outputFilePath, JSON.stringify(tests));
var tgen = process.hrtime(tgen);
console.log(`Generate time: ${(tgen[0] * 1e9 + tgen[1]).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")} nanoseconds`);

performanceUtil.performanceTest(tests);

practice1.performance-test.js:

'use strict';

require('util');
const performanceUtil = require('./performance-utils');
const fs = require('fs');
const path = require('path');
const outputFilePath = path.join(__dirname, process.argv[2] || 'test-data.json');

var tests = JSON.parse(fs.readFileSync(outputFilePath));
performanceUtil.performanceTest(tests);

【问题讨论】:

  • 你运行的是什么版本的节点?
  • 嘿,@SamH。我正在使用节点 v6.11
  • 刚刚在 8.5.0 Current 上测试并得到了相同的结果。

标签: javascript node.js ecmascript-6 execution-time


【解决方案1】:

正如您已经注意到的,性能差异导致了比较:generated array 与 JSON.parsed。我们在这两种情况下都有什么:具有相同数字的相同数组?那么查找性能必须相同吗?没有。

每个 Javascript 引擎都有不同的数据类型结构来表示相同的值(数字、对象、数组等)。在大多数情况下,优化器会尝试找出要使用的最佳数据类型。并且还经常生成一些额外的元信息,例如数组的hidden clases 或tags。

关于数据类型有几篇非常不错的文章:

那么为什么JSON.parse 创建的数组很慢呢?解析器在创建值时没有正确优化数据结构,因此我们得到 untagged 数组和 boxed 双精度。但是我们可以在之后使用Array.from 优化数组,在您的情况下,与生成的数组相同,您会得到带有smi 数字的smi 数组。这是基于您的示例的示例。

const fs = require('fs');
const path = require('path');
const outputFilePath = path.join(__dirname, process.argv[2] || 'test-data.json');

let tests = JSON.parse(fs.readFileSync(outputFilePath));

// for this demo we take only the first items array
var arrSlow = tests[0].input;
// `slice` copies array as-is
var arrSlow2 = tests[0].input.slice();
// array is copied and optimized
var arrFast = Array.from(tests[0].input);

console.log(%HasFastSmiElements(arrFast), %HasFastSmiElements(arrSlow), %HasFastSmiElements(arrSlow2));
//> true, false, false
console.log(%HasFastObjectElements(arrFast), %HasFastObjectElements(arrSlow), %HasFastObjectElements(arrSlow2));
//> false, true, true
console.log(%HasFastDoubleElements(arrFast), %HasFastDoubleElements(arrSlow), %HasFastDoubleElements(arrSlow2));
//> false, false, false

// small numbers and unboxed doubles in action
console.log(%HasFastDoubleElements([Math.pow(2, 31)]));
console.log(%HasFastSmiElements([Math.pow(2, 30)]));

使用node --allow-natives-syntax test.js 运行它

【讨论】:

    【解决方案2】:

    好的...首先让我们谈谈测试策略...

    多次运行此测试会产生令人难以置信的不同结果,每个点波动很大...查看结果

    https://docs.google.com/spreadsheets/d/1Z95GtT85BljpNda4l-usPjNTA5lJtUmmcY7BVB8fFGQ/edit?usp=sharing

    测试更新后(连续运行 100 次测试并计算平均值)我认为执行时间的主要差异是:

    • indexOf 和 for 循环在 GENERATOR 场景中工作得更好
    • 二分查找和插值查找在 JSON 解析场景中效果更好

    请先看谷歌文档...

    好的..太好了...这件事更容易解释...基本上我们陷入了随机内存访问(二进制,插值搜索)和连续内存访问的情况(indexOf, for) 给出不同的结果


    嗯。让我们深入了解 NodeJS 的内存管理模型

    首先NodeJS有几种数组表示,我其实只知道两种——numberArray、objectArray(表示可以包含任何类型值的数组)

    让我们看看 GENERATOR 场景:

    在初始数组创建期间,NodeJS ABLE 可以检测到您的数组仅包含数字,因为数组仅从数字开始,并且没有添加任何其他类型的内容。这导致使用简单的内存分配策略,只是原始整数行在内存中一个接一个地移动......

    数组在内存中表示为array of raw numbers,这里很可能只有memory paging table有效果

    这一事实清楚地解释了为什么连续内存访问在这种情况下效果更好。

    让我们看看 JSON 解析场景:

    由于 JSON 的 JSON 解析结构是不可预测的(NodeJS 使用 JSON 流解析器(99.99% 置信度)),每个值都被认为是最适合 JSON 解析的,所以...

    数组在内存中表示为array of references to the numbers,只是因为在解析 JSON 时,这个解决方案在大多数情况下效率更高(而且没人关心(恶魔))

    就我们在堆中按小块分配内存而言,内存会以更流畅的方式填充

    同样在这个模型中 RANDOM 内存访问 提供了更好的结果,因为 NodeJS 引擎没有选项 - 为了优化访问时间,它创建了良好的 prefix tree 或 hash map 这在 随机内存访问场景

    这很好地解释了为什么 JSON 解析方案在二进制插值搜索中获胜

    【讨论】:

    • 非常感谢您的回答。我从这两个答案中学到了很多,但是由于我的赏金即将结束,我将不得不选择 tenbits',因为它以代码和文章的形式提供了专门针对 V8 的证明。
    • 好吧...只是...他回答了你的另一个问题...我最难回答的问题是解释为什么您只在二进制搜索中遇到“性能增益”...无论如何感谢您提出有趣的问题,我想了很久!
    猜你喜欢
    • 2021-09-23
    • 2015-01-27
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-10-16
    • 2016-05-15
    相关资源
    最近更新 更多