案例研究:使用 Blackfire.io 优化 CommonMark Markdown 解析器
您可能知道,我是 PHP 联盟的 CommonMark Markdown 解析器的作者和维护者。 这个项目有三个主要目标:
- 完全支持整个 CommonMark 规范 匹配 JS 参考实现的行为 编写良好且超级可扩展,以便其他人可以添加自己的功能。
最后一个目标可能是最具挑战性的,尤其是从性能的角度来看。 其他流行的 Markdown 解析器是使用具有大量正则表达式函数的单个类构建的。 从这个基准测试中可以看出,它使它们快如闪电:
图书馆平均。 解析时间 文件/类计数 Parsedown 1.6.0 2 ms 1 PHP Markdown 1.5.0 4 ms 4 PHP Markdown Extra 1.5.0 7 ms 6 CommonMark 0.12.0 46 ms 117
不幸的是,由于紧密耦合的设计和整体架构,很难(如果不是不可能的话)使用自定义逻辑扩展这些解析器。
对于 League 的 CommonMark 解析器,我们选择将可扩展性置于性能之上。 这导致了一种解耦的面向对象设计,用户可以轻松自定义。 这使其他人能够构建自己的集成、扩展和其他自定义项目。
该库的性能仍然不错——最终用户可能无法区分 42 毫秒和 2 毫秒(无论如何你应该缓存你渲染的 Markdown)。 尽管如此,我们仍然希望在不影响我们的主要目标的情况下尽可能地优化我们的解析器。 这篇博文解释了我们如何使用 Blackfire 来做到这一点。
使用 Blackfire 进行分析
Blackfire 是来自 SensioLabs 的人们的一个很棒的工具。 您只需将它附加到任何 Web 或 CLI 请求,并获得应用程序请求的这种令人敬畏的、易于理解的性能跟踪。 在本文中,我们将研究如何使用 Blackfire 来识别和优化 league/commonmark 库 0.6.1 版中发现的两个性能问题。
让我们首先分析 league/commonmark 解析 CommonMark 规范文档的内容所花费的时间:
稍后我们会将此基准与我们的更改进行比较,以衡量性能改进。
快速旁注:Blackfire 在分析事物时会增加开销,因此执行时间总是比平时长得多。 关注相对百分比变化而不是绝对的“挂钟”时间。
优化一
查看我们的初始基准,您可以轻松地看到内联解析 InlineParserEngine::parse()
占执行时间的 43.75%。 单击此方法会显示有关发生这种情况的原因的更多信息:
在这里我们看到 InlineParserEngine::parse()
正在打电话 Cursor::getCharacter()
79,194 次——针对 Markdown 文本中的每个字符一次。 这是 0.6.1 中此方法的部分(稍作修改)摘录:
public function parse(ContextInterface $context, Cursor $cursor)
{
// Iterate through every single character in the current line
while (($character = $cursor->getCharacter()) !== null) {
// Check to see whether this character is a special Markdown character
// If so, let it try to parse this part of the string
foreach ($matchingParsers as $parser) {
if ($res = $parser->parse($context, $inlineParserContext)) {
continue 2;
}
}
// If no parser could handle this character, then it must be a plain text character
// Add this character to the current line of text
$lastInline->append($character);
}
}
黑火告诉我们 parse()
花费超过 17% 的时间检查每个。 单身的。 特点。 一。 在。 A。 时间。 但是这 79,194 个字符中的大多数是不需要特殊处理的纯文本! 让我们优化一下。
让我们使用正则表达式来捕获尽可能多的非特殊字符,而不是在循环末尾添加单个字符:
public function parse(ContextInterface $context, Cursor $cursor)
{
// Iterate through every single character in the current line
while (($character = $cursor->getCharacter()) !== null) {
// Check to see whether this character is a special Markdown character
// If so, let it try to parse this part of the string
foreach ($matchingParsers as $parser) {
if ($res = $parser->parse($context, $inlineParserContext)) {
continue 2;
}
}
// If no parser could handle this character, then it must be a plain text character
// NEW: Attempt to match multiple non-special characters at once.
// We use a dynamically-created regex which matches text from
// the current position until it hits a special character.
$text = $cursor->match($this->environment->getInlineParserCharacterRegex());
// Add the matching text to the current line of text
$lastInline->append($character);
}
}
进行此更改后,我使用 Blackfire 重新分析了库:
好的,情况看起来好多了。 但让我们使用 Blackfire 的比较工具实际比较这两个基准,以更清楚地了解发生了什么变化:
这个单一的变化导致对那个的调用减少了 48,118 次 Cursor::getCharacter()
方法和 11% 的整体性能提升! 这当然很有帮助,但我们可以进一步优化内联解析。
优化2
根据 CommonMark 规范:
一个换行符……前面有两个或多个空格……被解析为硬换行符(在 HTML 中呈现为标记)
因为这种语言,我本来就有 NewlineParser
停下来调查每一个空间和 n
它遇到的字符。 这是原始代码的示例:
class NewlineParser extends AbstractInlineParser {
public function getCharacters() {
return array("n", " ");
}
public function parse(ContextInterface $context, InlineParserContext $inlineContext) {
if ($m = $inlineContext->getCursor()->match('/^ *n/')) {
if (strlen($m) > 2) {
$inlineContext->getInlines()->add(new Newline(Newline::HARDBREAK));
return true;
} elseif (strlen($m) > 0) {
$inlineContext->getInlines()->add(new Newline(Newline::SOFTBREAK));
return true;
}
}
return false;
}
}
这些空格中的大多数都不是特别的,因此在每个空格处停下来并使用正则表达式检查它们是一种浪费。 您可以在原始 Blackfire 配置文件中轻松查看性能影响:
我很震惊地看到 43.75% 的整个解析过程都在弄清楚是否应该将 12,982 个空格和换行符转换为 <br>
元素。 这是完全不能接受的,所以我着手优化它。
请记住,规范规定序列必须以换行符结尾(n
). 所以,我们不要停在每个空格字符处,而是停在换行符处,看看前面的字符是否是空格:
class NewlineParser extends AbstractInlineParser {
public function getCharacters() {
return array("n");
}
public function parse(ContextInterface $context, InlineParserContext $inlineContext) {
$inlineContext->getCursor()->advance();
// Check previous text for trailing spaces
$spaces = 0;
$lastInline = $inlineContext->getInlines()->last();
if ($lastInline && $lastInline instanceof Text) {
// Count the number of spaces by using some `trim` logic
$trimmed = rtrim($lastInline->getContent(), ' ');
$spaces = strlen($lastInline->getContent()) - strlen($trimmed);
}
if ($spaces >= 2 ) {
$inlineContext->getInlines()->add(new Newline(Newline::HARDBREAK));
} else {
$inlineContext->getInlines()->add(new Newline(Newline::SOFTBREAK));
}
return true;
}
}
修改到位后,我重新分析了应用程序并看到了以下结果:
NewlineParser::parse()
现在只调用了 1,704 次而不是 12,982 次(减少了 87%) 一般内联解析时间减少了 61% 整体解析速度提高了 23%
概括
实施这两项优化后,我重新运行了 league/commonmark 基准测试工具以确定实际性能影响:
之前:59ms 之后:28ms
通过两个简单的更改,性能提升高达 52.5%!
能够查看性能成本(执行时间和函数调用次数)对于识别这些性能消耗至关重要。 我非常怀疑如果没有访问此性能数据,这些问题是否会被注意到。
分析对于确保您的代码快速高效地运行至关重要。 如果您还没有分析工具,那么我强烈建议您检查一下。 我个人最喜欢的恰好是 Blackfire(即“免费增值”),但也有其他分析工具。 它们的工作方式都略有不同,因此环顾四周,找到最适合您和您的团队的。
这篇文章未经编辑的版本最初发布在 Colin 的博客上。 经作者许可在此处重新发布。