21

在我的程序中,我处理了数百万个具有特殊字符的字符串,例如“|” 分隔每个字符串中的标记。我有一个返回第 n 个令牌的函数,就是这样:

function GetTok(const Line: string; const Delim: string; const TokenNum: Byte): string;
{ LK Feb 12, 2007 - This function has been optimized as best as possible }
var
 I, P, P2: integer;
begin
  P2 := Pos(Delim, Line);
  if TokenNum = 1 then begin
    if P2 = 0 then
      Result := Line
    else
      Result := copy(Line, 1, P2-1);
  end
  else begin
    P := 0; { To prevent warnings }
    for I := 2 to TokenNum do begin
      P := P2;
      if P = 0 then break;
      P2 := PosEx(Delim, Line, P+1);
    end;
    if P = 0 then
      Result := ''
    else if P2 = 0 then
      Result := copy(Line, P+1, MaxInt)
    else
      Result := copy(Line, P+1, P2-P-1);
  end;
end; { GetTok }

我在使用 Delphi 4 时开发了这个函数。它调用了非常高效的 PosEx 例程,该例程最初由 Fastcode 开发,现在包含在 Delphi 的 StrUtils 库中。

我最近升级到 Delphi 2009,我的字符串都是 Unicode。这个 GetTok 函数仍然有效并且仍然有效。

我浏览了 Delphi 2009 中的新库,其中有许多新功能和新增功能。

但是我没有在任何新的 Delphi 库和各种 fastcode 项目中看到像我需要的 GetToken 函数,而且除了Zarko Gajic 的:Delphi Split / Tokenizer Functions之外,我无法通过 Google 搜索找到任何东西,这不是和我已经拥有的一样优化。

在我的程序中,任何改进,即使是 10% 也会很明显。我知道另一种选择是 StringLists 并始终将标记分开,但这在内存方面有很大的开销,我不确定我是否做了所有的工作来转换它是否会更快。

唷。所以在所有这些冗长的谈话之后,我的问题真的是:

您知道 GetToken 例程的任何非常快速的实现吗?汇编程序优化版本是理想的吗?

如果没有,您是否可以看到我上面的代码的任何优化可能会有所改进?


跟进:Barry Kelly 提到了我一年前提出的一个关于优化文件中行的解析的问题。那时我什至没有想到我的 GetTok 例程没有用于读取或解析。直到现在我才看到我的 GetTok 例程的开销导致我提出这个问题。在 Carl Smotricz 和 Barry 给出答案之前,我从未想过将两者联系起来。如此明显,但它只是没有注册。感谢您指出了这一点。

是的,我的 Delim 是单个字符,所以显然我可以做一些重大的优化。我在 GetTok 例程(上图)中使用 Pos 和 PosEx 让我不知道我可以用逐个字符的搜索来更快地做到这一点,代码如下:

      while (cp^ > #0) and (cp^ <= Delim) do    
        Inc(cp);

我将浏览每个人的答案并尝试各种建议并进行比较。然后我会发布结果。


困惑:好吧,现在我真的很困惑。

我接受了 Carl 和 Barry 的建议来使用 PChars,这是我的实现:

function GetTok(const Line: string; const Delim: string; const TokenNum: Byte): string;
{ LK Feb 12, 2007 - This function has been optimized as best as possible }
{ LK Nov 7, 2009 - Reoptimized using PChars instead of calls to Pos and PosEx }
{ See; https://stackoverflow.com/questions/1694001/is-there-a-fast-gettoken-routine-for-delphi }
var
 I: integer;
 PLine, PStart: PChar;
begin
  PLine := PChar(Line);
  PStart := PLine;
  inc(PLine);
  for I := 1 to TokenNum do begin
    while (PLine^ <> #0) and (PLine^ <> Delim) do
      inc(PLine);
    if I = TokenNum then begin
      SetString(Result, PStart, PLine - PStart);
      break;
    end;
    if PLine^ = #0 then begin
      Result := '';
      break;
    end;
    inc(PLine);
    PStart := PLine;
  end;
end; { GetTok }

在纸面上,我认为你不能做得比这更好。

所以我把这两个例程都放到了任务中,并使用 AQTime 来查看发生了什么。我的运行包括对 GetTok 的 1,108,514 次调用。

AQTime 将原始程序计时为 0.40 秒。对 Pos 的百万次调用只用了 0.10 秒。半百万的 TokenNum = 1 个副本需要 0.10 秒。600,000 个 PosEx 调用只用了 0.03 秒。

然后我用 AQTime 为我的新例程计时,以进行相同的运行和完全相同的调用。AQTime 报告说我的新“快速”程序耗时 3.65 秒,是 9 倍。根据 AQTime 的罪魁祸首是第一个循环:

     while (PLine^ <> #0) and (PLine^ <> Delim) do
       inc(PLine);

执行了 1800 万次的 while 行被报告为 2.66 秒。inc 行执行了 1600 万次,据说需要 0.47 秒。

现在我想我知道这里发生了什么。我在去年提出的一个问题中遇到了与 AQTime 类似的问题:为什么 CharInSet 比 Case 语句快?

再次是 Barry Kelly 向我提供了线索。基本上,像 AQTime 这样的检测分析器不一定能完成微优化的工作。它为每条线路增加了开销,这可能会淹没这些数字中清楚显示的结果。在我的新“优化代码”中执行的 3400 万行超过了我原来的几百万行代码,显然 Pos 和 PosEx 例程的开销很少或没有。

Barry 给了我一个使用 QueryPerformanceCounter 的代码示例来检查他是否正确,在这种情况下他是正确的。

好的,现在让我们用 QueryPerformanceCounter 做同样的事情来证明我的新例程更快,而不是像 AQTime 所说的那样慢 9 倍。所以我来了:

function TimeIt(const Title: string): double;
var  i: Integer;
  start, finish, freq: Int64;
  Seconds: double;
begin
  QueryPerformanceCounter(start);
  for i := 1 to 250000 do
    GetTokOld('This is a string|that needs|parsing', '|', 1);
  for i := 1 to 250000 do
    GetTokOld('This is a string|that needs|parsing', '|', 2);
  for i := 1 to 250000 do
    GetTokOld('This is a string|that needs|parsing', '|', 3);
  for i := 1 to 250000 do
    GetTokOld('This is a string|that needs|parsing', '|', 4);
  QueryPerformanceCounter(finish);
  QueryPerformanceFrequency(freq);
  Seconds := (finish - start) / freq;
  Result := Seconds;
end;

所以这将测试对 GetTok 的 1,000,000 次调用。

我使用 Pos 和 PosEx 调用的旧程序耗时 0.29 秒。带有 PChars 的新版本耗时 2.07 秒。

现在我完全糊涂了!谁能告诉我为什么 PChar 程序不仅慢,而且慢 8 到 9 倍!?


谜团已揭开!Andreas 在他的回答中说将 Delim 参数从字符串更改为 Char。我将永远只使用一个字符,所以至少对于我的实现来说这是很有可能的。我对发生的事情感到惊讶。

100 万次调用的时间从 1.88 秒下降到 0.22 秒。

令人惊讶的是,当我将它的 Delim 参数更改为 Char 时,我原来的 Pos/PosEx 例程的时间从 0.29 秒增加到 0.44 秒。

坦率地说,我对 Delphi 的优化器很失望。Delim 是一个常数参数。优化器应该已经注意到循环中发生了相同的转换,并且应该将其移出以便只执行一次。

仔细检查我的代码生成参数,是的,我确实有优化 True 和字符串格式检查关闭。

底线是 Andrea 修复的新 PChar 例程比我原来的快 25%(0.22 对 0.29)。

我仍然想在这里跟进其他评论并对其进行测试。


关闭优化并打开字符串格式检查只会将时间从 0.22 增加到 0.30。它在原版的基础上增加了大约相同的内容。

使用汇编程序代码或调用用 Pos 或 PosEx 等汇编程序编写的例程的优点是它们不受您设置的代码生成选项的影响。它们将始终以相同的方式运行,一种预先优化且不臃肿的方式。

我在过去几天重申,比较微优化代码的最佳方法是查看和比较 CPU 窗口中的汇编程序代码。如果 Embarcadero 可以使该窗口更方便一点,并允许我们将部分复制到剪贴板或打印它的部分,那就太好了。

此外,我在这篇文章的前面不公平地抨击了 AQTime,认为为我的新例程增加的额外时间仅仅是因为它添加了仪器。现在我返回并检查 Char 参数而不是 String,while 循环下降到 0.30 秒(从 2.66 开始),而 inc 行下降到 0.14 秒(从 0.47 开始)。奇怪的是,inc 线也会下降。但是我已经厌倦了所有这些测试。


我采用了 Carl 的按字符循环的想法,并用这个想法重写了该代码。它做出了另一项改进,从 0.22 秒降至 0.19 秒。所以这是迄今为止最好的:

function GetTok(const Line: string; const Delim: Char; const TokenNum: Byte): string;
{ LK Nov 8, 2009 - Reoptimized using PChars instead of calls to Pos and PosEx }
{ See; https://stackoverflow.com/questions/1694001/is-there-a-fast-gettoken-routine-for-delphi }
var
  I, CurToken: Integer;
  PLine, PStart: PChar;
begin
  CurToken := 1;
  PLine := PChar(Line);
  PStart := PLine;
  for I := 1 to length(Line) do begin
    if PLine^ = Delim then begin
      if CurToken = TokenNum then
        break
      else begin
        CurToken := CurToken + 1;
        inc(PLine);
        PStart := PLine;
      end;
    end
    else
      inc(PLine);
  end;
  if CurToken = TokenNum then
    SetString(Result, PStart, PLine - PStart)
  else
    Result := '';
end;

对此可能还有一些小的优化,比如 CurToken = Tokennum 比较,应该是相同的类型,Integer 或 Byte,以较快者为准。

但是,让我们说,我现在很高兴。

再次感谢 StackOverflow Delphi 社区。

4

7 回答 7

12

“Delim”的预期是什么有很大的不同。如果预计它是单个字符,那么最好逐个字符地遍历字符串,最好是通过 PChar,并专门进行测试。

如果它是一个长字符串,Boyer-Moore 和类似的搜索有一个跳过表的设置阶段,最好的方法是构建表一次,然后为每个后续查找重用它们。这意味着您需要调用之间的状态,而这个函数最好作为对象上的方法。

您可能对我之前对一个问题的回答感兴趣,关于在 Delphi 中解析行的最快方法。(但我看到是您提出了这个问题!不过,在解决您的问题时,我会遵守我描述解析的方式,而不是像您使用的那样使用 PosEx,这取决于 Delim 通常的样子。)


更新:好的,我花了大约 40 分钟看这个。如果您知道分隔符将是一个字符,那么使用第二个版本(即 PChar 扫描)几乎总是更好,但您必须Delim作为字符传递。在撰写本文时,您正在将PLine^Char 类型的表达式转换为字符串,以便与 Delim 进行比较。那会很慢;即使索引到字符串中,withDelim[1]也会有些慢。

但是,根据您的行有多大,以及您想要拉出多少分隔部分,您可能最好采用可恢复的方法,而不是在标记化例程中跳过不需要的分隔部分。如果您使用连续增加的索引调用 GetTok,就像您目前在迷你基准测试中所做的那样,您最终将获得 O(n*n) 性能,其中 n 是分隔部分的数量。如果您保存扫描状态并将其恢复以进行下一次迭代,或者将所有提取的项目打包到一个数组中,则可以将其转换为 O(n)。

这是一个执行所有标记化一次并返回一个数组的版本。它需要标记两次,以便知道数组的大小。另一方面,只有第二个标记化需要提取字符串:

// Do all tokenization up front.
function GetTok4(const Line: string; const Delim: Char): TArray<string>;
var
  cp, start: PChar;
  count: Integer;
begin
  // Count sections
  count := 1;
  cp := PChar(Line);
  start := cp;
  while True do
  begin
    if cp^ <> #0 then
    begin
      if cp^ <> Delim then
        Inc(cp)
      else
      begin
        Inc(cp);
        Inc(count);
      end;
    end
    else
    begin
      Inc(count);
      Break;
    end;
  end;

  SetLength(Result, count);
  cp := start;
  count := 0;

  while True do
  begin
    if cp^ <> #0 then
    begin
      if cp^ <> Delim then
        Inc(cp)
      else
      begin
        SetString(Result[count], start, cp - start);
        Inc(cp);
        Inc(count);
      end;
    end
    else
    begin
      SetString(Result[count], start, cp - start);
      Break;
    end;
  end;
end;

这是可恢复的方法。但是,当前位置和分隔符的加载和存储确实有成本:

type
  TTokenizer = record
  private
    FSource: string;
    FCurrPos: PChar;
    FDelim: Char;
  public
    procedure Reset(const ASource: string; ADelim: Char); inline;
    function GetToken(out AResult: string): Boolean; inline;
  end;

procedure TTokenizer.Reset(const ASource: string; ADelim: Char);
begin
  FSource := ASource; // keep reference alive
  FCurrPos := PChar(FSource);
  FDelim := ADelim;
end;

function TTokenizer.GetToken(out AResult: string): Boolean;
var
  cp, start: PChar;
  delim: Char;
begin
  // copy members to locals for better optimization
  cp := FCurrPos;
  delim := FDelim;

  if cp^ = #0 then
  begin
    AResult := '';
    Exit(False);
  end;

  start := cp;
  while (cp^ <> #0) and (cp^ <> Delim) do
    Inc(cp);

  SetString(AResult, start, cp - start);
  if cp^ = Delim then
    Inc(cp);
  FCurrPos := cp;
  Result := True;
end;

这是我用于基准测试的完整程序。

结果如下:

*** count=3, Length(src)=200
GetTok1: 595 ms
GetTok2: 547 ms
GetTok3: 2366 ms
GetTok4: 407 ms
GetTokBK: 226 ms
*** count=6, Length(src)=350
GetTok1: 1587 ms
GetTok2: 1502 ms
GetTok3: 6890 ms
GetTok4: 679 ms
GetTokBK: 334 ms
*** count=9, Length(src)=500
GetTok1: 3055 ms
GetTok2: 2912 ms
GetTok3: 13766 ms
GetTok4: 947 ms
GetTokBK: 446 ms
*** count=12, Length(src)=650
GetTok1: 4997 ms
GetTok2: 4803 ms
GetTok3: 23021 ms
GetTok4: 1213 ms
GetTokBK: 543 ms
*** count=15, Length(src)=800
GetTok1: 7417 ms
GetTok2: 7173 ms
GetTok3: 34644 ms
GetTok4: 1480 ms
GetTokBK: 653 ms

根据数据的特征、分隔符是否可能是字符以及使用它的方式,不同的方法可能更快。

(我在早期的程序中犯了一个错误,我没有为每种例程测量相同的操作。我更新了 pastebin 链接和基准测试结果。)

于 2009-11-08T03:54:15.290 回答
11

您的新函数(带有 PChar 的函数)应将“Delim”声明为Char而不是String。在您当前的实现中,编译器必须将 PLine^ 字符转换为字符串以将其与“Delim”进行比较。这发生在一个紧密的循环中,结果是一个巨大的性能损失。

function GetTok(const Line: string; const Delim: Char{<<==}; const TokenNum: Byte): string;
{ LK Feb 12, 2007 - This function has been optimized as best as possible }
{ LK Nov 7, 2009 - Reoptimized using PChars instead of calls to Pos and PosEx }
{ See; http://stackoverflow.com/questions/1694001/is-there-a-fast-gettoken-routine-for-delphi }
var
 I: integer;
 PLine, PStart: PChar;
begin
  PLine := PChar(Line);
  PStart := PLine;
  inc(PLine);
  for I := 1 to TokenNum do begin
    while (PLine^ <> #0) and (PLine^ <> Delim) do
      inc(PLine);
    if I = TokenNum then begin
      SetString(Result, PStart, PLine - PStart);
      break;
    end;
    if PLine^ = #0 then begin
      Result := '';
      break;
    end;
    inc(PLine);
    PStart := PLine;
  end;
end; { GetTok }
于 2009-11-08T09:43:14.597 回答
9

Delphi 编译成非常高效的代码;根据我的经验,很难在汇编程序中做得更好。

我认为您应该只在字符串的开头指向一个 PChar (它们仍然存在,不是吗?我在 4.0 左右与 Delphi 分道扬镳)并在计算“|”的同时递增它,直到找到 n-1其中。我怀疑这会比反复调用 PosEx 更快。

记下那个位置,然后再增加指针一点,直到你碰到下一个管道。拉出你的子串。完毕。

我只是猜测,但如果这接近于最快的解决这个问题,我不会感到惊讶。

编辑:这就是我的想法。唉,这段代码是未经编译和未经测试的,但它应该说明我的意思。

特别是,Delim 被视为单个字符,我相信如果它能够满足要求,它会产生很大的不同,并且 PLine 的字符只测试一次。最后,没有更多与 TokenNum 的比较;我相信将计数器减少到 0 来计算分隔符会更快。

function GetTok(const Line: string; const Delim: string; const TokenNum: Byte): string;
var 
  Del: Char;
  PLine, PStart: PChar;
  Nth, I, P0, P9: Integer;
begin
  Del := Delim[1];
  Nth := TokenNum + 1;
  P0 := 1;
  P9 := Line.length + 1;
  PLine := PChar(line);
  for I := 1 to P9 do begin
    if PLine^ = Del then begin
      if Nth = 0 then begin
        P9 := I;
        break;
      end;
      Dec(Nth);
      if Nth = 0 then P0 := I + 1
    end;
    Inc(PLine);
  end;
  if (Nth <= 1) or (TokenNum = 1) then
    Result := Copy(Line, P0, P9 - P0);
  else
    Result := '' 
end;
于 2009-11-07T19:08:40.607 回答
2

使用汇编程序将是一个微优化。通过优化算法可以获得更大的收益。不做工作胜过以最快的方式做工作,每次。

一个示例是,如果您的程序中有一些地方需要同一行的多个标记。另一个返回令牌数组的过程应该比多次调用你的函数更快,特别是如果你让过程不返回所有令牌,而只返回你需要的数量。

但总的来说,我同意 Carl 的回答 (+1),使用 aPChar进行扫描可能会比您当前的代码更快。

于 2009-11-07T19:33:25.483 回答
1

这是我在我的个人库中使用了很长时间的一个函数,我广泛使用。我相信这是它的最新版本。过去我有多个版本出于各种不同的原因进行了优化。这个尝试考虑带引号的字符串,但如果删除该代码,它会使函数稍微快一点。

实际上,我还有许多其他例程,CountSections 和 ParseSectionPOS 就是几个例子。

不幸的是,这个例程仅基于 ansi/pchar。尽管我认为将其移至 unicode 并不困难。也许我已经这样做了......我必须检查一下。

注意:此例程是基于 ParseNum 索引的 1。

function ParseSection(ParseLine: string; ParseNum: Integer; ParseSep: Char; QuotedStrChar:char = #0) : string;
var
   wStart, wEnd : integer;
   wIndex : integer;
   wLen : integer;
   wQuotedString : boolean;
begin
   result := '';
   wQuotedString := false;
   if not (ParseLine = '') then
   begin
      wIndex := 1;
      wStart := 1;
      wEnd := 1;
      wLen := Length(ParseLine);
      while wEnd <= wLen do
      begin
         if (QuotedStrChar <> #0) and (ParseLine[wEnd] = QuotedStrChar) then
            wQuotedString := not wQuotedString;

         if not wQuotedString and (ParseLine[wEnd] = ParseSep) then
         begin
            if wIndex=ParseNum then
               break
            else
            begin
               inc(wIndex);
               wStart := wEnd+1;
            end;
         end;
         inc(wEnd);
      end;

      result := copy(ParseLine, wStart, wEnd-wStart);
      if (length(result) > 0) and (QuotedStrChar <> #0) and (result[1] = QuotedStrChar) then
         result := AnsiDequotedStr(result, QuotedStrChar);
   end;
end; { ParseSection }
于 2009-11-08T01:08:53.350 回答
1

在您的代码中,我认为这是唯一可以优化的行:

Result := copy(Line, P+1, MaxInt)

如果你在那里计算新的长度,它可能会更快一点,但不是你正在寻找的 10%。

您的标记化算法似乎还不错。为了优化它,我会通过一个分析器(比如来自 AutomatedQA 的AQTime)运行它,其中包含生产数据的代表性子集。这将指向你最薄弱的地方。

唯一接近的 RTL 函数是 Classes 单元中的这个:

procedure TStrings.SetDelimitedText(const Value: string);

它标记化,但同时使用QuoteCharDelimiter,但您只使用 Delimiter。

它使用系统单元中的SetString函数,这是一种基于 PChar/PAnsiChar/PUnicodeChar 和长度设置字符串内容的非常快速的方法。

这也可能使您有所改善;另一方面,Copy也非常快。

于 2009-11-08T03:21:21.570 回答
1

我不是总是责备算法的人,但是如果我查看第一条源代码,问题是对于字符串 N,您也再次为字符串 1..n-1 执行 POS/posexes。

这意味着对于 N 个项目,您需要 sum (n, n-1,n-2...1) POSes (=+/- 0.5*N^2) ,而只需要 N 个。

如果您只是缓存最后找到的结果的位置,例如在通过 VAR 参数传递的记录中,您可以获得很多。

类型
TLastPosition = 记录元素nr:整数;// 最后一个标记号 elementpos: integer; // 最后匹配结束的字符索引;

然后是一些东西

如果 tokennum=(lastposition.elementnr+1) 然后开始 newpos:=posex(delim,line,lastposition.elementpos); 结尾;

不幸的是,我现在没有时间写出来,但我希望你能明白

于 2009-11-09T13:00:00.530 回答