Jump to content
Sign in to follow this  
Mike Torrettinni

Micro optimization: Split strings

Recommended Posts

I use various different ways to split strings by delimiter: using TStringList, string helper Split or System.StrUtils.SplitString, most of the time TStringList. To refactor and choose fastest one I did some test and also have custom split function.

I'm just looking for fastest, so the type of result is less important than the performance:

 

Interesting is that with short strings, pre-counting delimiters to correctly initialize array is faster, but when using longer strings, pre-counting slows down the performance.

 

UsingStringList
Short str: 509
Medium str: 1107
Long str: 3473

UsingSplitHelper
Short str: 293
Medium str: 692
Long str: 2116

UsingSplitString
Short str: 476
Medium str: 1413
Long str: 5909

winners: 

* CustomSplitWithPrecount - count delimiters to initialize array
Short str: 178
Medium str: 474
Long str: 1659

* CustomSplitNoPrecount - no counting of delimiters, resize array at the end
Short str: 184
Medium str: 457
Long str: 1477

 

program Project1;

{$APPTYPE CONSOLE}

{$R *.res}

uses
  System.SysUtils, System.StrUtils, System.Classes, System.Diagnostics, System.Generics.Collections, System.Generics.Defaults, System.Types;

const
  cMaxLoop = 1000000;
  cDelimiter  : char = ',';
  cShortStr   : string = 'word,string,character';
  cMediumStr  : string = 'cat,dog,mouse,horse,pigeon,tiger,worm,lion,turtle,fish';
  cLongStr    : string = 'black,maroon,green,dark green,light green,olive,navy,purple,teal,silver,grey,red,ligh blue,dark blue,navy blue,cyan,grey,white,aqua,teal,silver,orange,violet,blue violet,dark red,deep pink,steel blue,sea blue,aquamarine,medium turquoise,violet,last colorX';

var
  xStrList: TStringList;
  xArray: TArray<string>;
  xStrDynArray: TStringDynArray;
  xSW: TStopWatch;
  i: integer;

function UsingStringList(const aString: string; const aDelimiter: char): TStringList;
begin
  Result := TStringList.Create;
  Result.StrictDelimiter := True;
  Result.Delimiter       := aDelimiter;
  Result.DelimitedText   := aString;
end;

function UsingSplitHelper(const aString: string; const aDelimiter: char): TArray<string>;
begin
  Result := aString.Split([aDelimiter]);
end;

function UsingSplitString(const aString: string; const aDelimiter: char): TStringDynArray;
begin
  Result := System.StrUtils.SplitString(aString, aDelimiter);
end;

function CustomSplitWithPrecount(const aString: string; const aDelimiter: Char): TArray<string>;
var i, c: Integer;
    vCurrPos, vCurrTokenStart: PChar;
begin
  vCurrPos := PChar(aString);

  // count delimiters to set array size
  c := 0;
  for i := 1 to aString.Length do
  begin
    if vCurrPos^ = aDelimiter then
      Inc(c);
    inc(vCurrPos);
  end;

  if c = 0 then
    Exit  // exit if no delimiters found
  else
    SetLength(Result, c + 1); // tokens = no of delimiters + 1

  // parse
  c := 0;
  vCurrPos := PChar(aString);
  vCurrTokenStart := vCurrPos;
  for i := 1 to length(aString) do
  begin
    if vCurrPos^ = aDelimiter then
    begin
      // save Token
      SetString(Result[c], vCurrTokenStart, vCurrPos - vCurrTokenStart);
      inc(c);
      inc(vCurrPos);
      vCurrTokenStart := vCurrPos;

      // stop looping string at last delimiter
      if c = Length(Result) - 1 then
      begin
        Inc(vCurrPos, aString.Length - i);
        Break;
      end;
    end
    else
      inc(vCurrPos);
  end;

  // get last token
  SetString(Result[c], vCurrTokenStart, vCurrPos - vCurrTokenStart);
end;

function CustomSplitNoPrecount(const aString: string; const aDelimiter: Char): TArray<string>;
var i, c: Integer;
    vCurrPos, vCurrTokenStart: PChar;
begin
  // Preset array size to max
  SetLength(Result,  aString.Length);

  if aString.Length = 0 then
    Exit;

  // parse
  c := 0;
  vCurrPos := PChar(aString);
  vCurrTokenStart := vCurrPos;
  for i := 1 to length(aString) do
  begin
    if vCurrPos^ = aDelimiter then
    begin
      // save Token
      SetString(Result[c], vCurrTokenStart, vCurrPos - vCurrTokenStart);
      inc(c);
      inc(vCurrPos);
      vCurrTokenStart := vCurrPos;

      // stop looping string at last delimiter
      if c = Length(Result) - 1 then
      begin
        Inc(vCurrPos, aString.Length - i);
        Break;
      end;
    end
    else
      inc(vCurrPos);
  end;

  // get last token
  SetString(Result[c], vCurrTokenStart, vCurrPos - vCurrTokenStart);

  // re-set filan array size
  SetLength(Result, c + 1);
end;


begin

  Writeln('UsingStringList');
  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
  begin
    xStrList := UsingStringList(cShortStr, cDelimiter);
    xStrList.Free;
  end;
  Writeln('Short str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
  begin
    xStrList := UsingStringList(cMediumStr, cDelimiter);
    xStrList.Free;
  end;
  Writeln('Medium str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
  begin
    xStrList := UsingStringList(cLongStr, cDelimiter);
    xStrList.Free;
  end;
  Writeln('Long str: ' + xSW.ElapsedMilliseconds.ToString);

  writeln;

  Writeln('UsingSplitHelper');
  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xArray := UsingSplitHelper(cShortStr, cDelimiter);
  Writeln('Short str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xArray := UsingSplitHelper(cMediumStr, cDelimiter);
  Writeln('Medium str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xArray := UsingSplitHelper(cLongStr, cDelimiter);
  Writeln('Long str: ' + xSW.ElapsedMilliseconds.ToString);

  writeln;

  Writeln('UsingSplitString');
  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xStrDynArray := UsingSplitString(cShortStr, cDelimiter);
  Writeln('Short str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xStrDynArray := UsingSplitString(cMediumStr, cDelimiter);
  Writeln('Medium str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xStrDynArray := UsingSplitString(cLongStr, cDelimiter);
  Writeln('Long str: ' + xSW.ElapsedMilliseconds.ToString);

  writeln;

  Writeln('CustomSplitWithPrecount');
  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xArray := CustomSplitWithPrecount(cShortStr, cDelimiter);
  Writeln('Short str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xArray := CustomSplitWithPrecount(cMediumStr, cDelimiter);
  Writeln('Medium str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xArray := CustomSplitWithPrecount(cLongStr, cDelimiter);
  Writeln('Long str: ' + xSW.ElapsedMilliseconds.ToString);

  writeln;

  Writeln('CustomSplitNoPrecount');
  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xArray := CustomSplitNoPrecount(cShortStr, cDelimiter);
  Writeln('Short str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xArray := CustomSplitNoPrecount(cMediumStr, cDelimiter);
  Writeln('Medium str: ' + xSW.ElapsedMilliseconds.ToString);

  xSW := TStopWatch.StartNew;
  for i := 1 to cMaxLoop do
    xArray := CustomSplitNoPrecount(cLongStr, cDelimiter);
  Writeln('Long str: ' + xSW.ElapsedMilliseconds.ToString);

 

  readln;

end.

 

Anybody have example of faster function that splits strings by delimiter?

 

 

Share this post


Link to post

Your code is sometimes not correct.

For instance, CustomSplitWithPrecount() exit directly without setting result := nil so it won't change the value passed as input (remember than an array result is in fact a "var" appended argument).

 

All those are microoptimisations - not worth it unless you really need it.
I would not use TStringList for sure. But any other method is good enough in most cases.

 

Also no need to use a PChar and increment it.
In practice, a loop with an index of the string is safer - and slightly faster since you use only the i variable which is already incremented each time.

 

To optimize any further, I would use PosEx() to find the delimiter which may be faster than your manual search on some targets.

 

The golden rule is to make it right first.

Then make it fast - only if it is worth it, and I don't see why it would be worth it.

Edited by Arnaud Bouchez
  • Thanks 1

Share this post


Link to post

My version is rather fun as some kind of pico-optimization. An on the fly enumerator!

And also fast and low memory footprint. (And unidirectional 😉

  TSplitEnumerator = class;

  TSplit = record
  private
    PValue: pointer;
    PDelimiter: pointer;
  public
    function GetEnumerator: TSplitEnumerator;
    class function Create(const AValue: string; const ADelimiter: string): TSplit; static;
  end;

  TSplitEnumerator = class
  private
    FValue: string;
    FDelimiter: string;
    FHasNext: boolean;
    FIndex: integer;
    FNIndex: integer;
    FLen: integer;
    FDelLen: integer;
  public
    constructor Create(const ASplit: TSplit);
    function MoveNext: boolean; {$IFNDEF DEBUG} inline; {$ENDIF}
    function GetCurrent: string; {$IFNDEF DEBUG} inline; {$ENDIF}
    property Current: string read GetCurrent;
  end;

{ TSplit }


class function TSplit.Create(const AValue: string; const ADelimiter: string): TSplit;
begin
  Result.PValue := pointer(AValue);
  Result.PDelimiter := pointer(ADelimiter);
end;


function TSplit.GetEnumerator: TSplitEnumerator;
begin
  Result := TSplitEnumerator.Create(Self);
end;

{ TSplitEnumerator }


constructor TSplitEnumerator.Create(const ASplit: TSplit);
begin
  FIndex := 1;
  pointer(FValue) := ASplit.PValue;
  pointer(FDelimiter) := ASplit.PDelimiter;
  FLen := Length(FValue);
  FDelLen := Length(FDelimiter);
  FNIndex := Pos(FDelimiter, FValue, FIndex);
  if (FNIndex = 0) then
    FNIndex := FIndex + FLen;
  FHasNext := (FLen > 0) and (FNIndex > 0);
end;


function TSplitEnumerator.GetCurrent: string;
begin
  Result := Copy(FValue, FIndex, FNIndex - FIndex);
  if FNIndex + FDelLen < FLen then
  begin
    FIndex := FNIndex + FDelLen;
    FNIndex := Pos(FDelimiter, FValue, FIndex);
    if FNIndex = 0 then
      FNIndex := FLen + FDelLen;
  end
  else
    FHasNext := False;
end;


function TSplitEnumerator.MoveNext: boolean;
begin
  Result := FHasNext;
end;

var
  s: string;
begin

  for s in TSplit.Create(cShortStr, cDelimiter) do
    WriteLn(s);
    
end;

 

Edited by Attila Kovacs
  • Like 1

Share this post


Link to post
1 hour ago, Arnaud Bouchez said:

For instance, CustomSplitWithPrecount() exit directly without setting result := nil so it won't change the value passed as input (remember than an array result is in fact a "var" appended argument).

Thanks, good eyes!

1 hour ago, Arnaud Bouchez said:

The golden rule is to make it right first.

Well, yes, it might seems the opposite, but I do try to make it right 😉 I need more practice,

Share this post


Link to post

In case any other Delphi rookies are using TStringList to split strings and are thinking why I'm looking for better replacement:

in older part of the code, before I knew about StrictDelimiter property, I used StringReplace to replace spaces, otherwise it would split by spaces also! 😉

 

So, I would StringReplace spaces, then parse with TStringList and then reverse back to spaces, with StringReplace again, I hope nobody else does this 😉

 

Edited by Mike Torrettinni

Share this post


Link to post

Actually, seems like pre-counting and initializing array is faster in Release mode - for all 3 lengths of strings:

 

CustomSplitWithPrecount
Short str: 150
Medium str: 394
Long str: 1265

CustomSplitNoPrecount
Short str: 175
Medium str: 427
Long str: 1303

 

Edited by Mike Torrettinni

Share this post


Link to post
3 hours ago, Arnaud Bouchez said:

To optimize any further, I would use PosEx() to find the delimiter which may be faster than your manual search on some targets.

This is my try with Pos, but is slower:

 

CustomSplitWithPrecount
Short str: 148
Medium str: 389
Long str: 1227

CustomSplitWithPrecountPos
Short str: 207
Medium str: 542
Long str: 1773
function CustomSplitWithPrecountPos(const aString: string; const aDelimiter: Char): TArray<string>;
var i, c, p,s: Integer;
    vCurrPos, vCurrTokenStart: PChar;
begin
  vCurrPos := PChar(aString);

  // count delimiters to set array size
  c := 0;
  for i := 1 to aString.Length do
  begin
    if vCurrPos^ = aDelimiter then
      Inc(c);
    inc(vCurrPos);
  end;

  if c = 0 then
    Exit(nil)  // exit if no delimiters found
  else
    SetLength(Result, c + 1); // tokens = no of delimiters + 1

  // parse
  c := 0;
  s := 1;
  p := 0;
  repeat
    p := Pos(aDelimiter, aString, p + 1);
    if p <> 0 then
    begin
      // save Token
      SetString(Result[c], PChar(@aString[s]), p - s);
      inc(c);
      s := p+1;
    end;
  until  p = 0;

  // get last token
  SetString(Result[c], PChar(@aString[s]), aString.Length - s + 1);
end;

 

Share this post


Link to post
10 hours ago, Arnaud Bouchez said:

Also no need to use a PChar and increment it.
In practice, a loop with an index of the string is safer - and slightly faster since you use only the i variable which is already incremented each time.

I thought PChar is faster... not sure why. It is marginally little faster function by index, except for longer strings PChar solution is marginally faster:

 

CustomSplitWithPrecount2
Short str: 150
Medium str: 395
Long str: 1221

CustomSplitWithPrecountByIndex
Short str: 145
Medium str: 386
Long str: 1228

 

But this is negligent difference. And index based solution is a little easier to handle, for me.

 

So, here is the winner so far - with pre-calculated string length:

 

function CustomSplitWithPrecountByIndex(const aString: string; const aDelimiter: Char): TArray<string>;
var i, c2, c, s, vLen: Integer;
begin
  vLen := aString.Length;

  // count delimiters to set array size
  c2 := 0;
  for i := 1 to vLen do
    if aString[i] = aDelimiter then
      Inc(c2);

  if c2 = 0 then
    Exit(nil)  // exit if no delimiters found
  else
    SetLength(Result, c2 + 1); // tokens = no of delimiters + 1

  // parse
  c := 0;
  s := 1;
  for i := 1 to vLen do
  begin
    if aString[i] = aDelimiter then
    begin
      // save Token
      SetString(Result[c], PChar(@aString[s]), i - s);
      inc(c);
      s := i + 1;

      // stop looping string at last delimiter
      if c = c2 then
        Break;
    end;
  end;

  // get last token
  SetString(Result[c], PChar(@aString[s]), vLen - s + 1);
end;

 

Share this post


Link to post

Some of your functions have a defect as in returning an empty array when no delimiter is found - they must return a 1 element array with the input string if they should follow RTL behavior.

Also you can remove some unnecessary branching and make the code simpler:

 

function CustomSplitWithPrecountByIndex2(const aString: string; const aDelimiter: Char): TArray<string>;
var
  i, resultLen, resultIdx, tokenPos, inputLen, lastDelimiterPos: Integer;
begin
  inputLen := aString.Length;

  lastDelimiterPos := 0;
  resultLen := 1;
  for i := 1 to inputLen do
    if aString[i] = aDelimiter then
    begin
      Inc(resultLen);
      lastDelimiterPos := i;
    end;

  SetLength(Result, resultLen);

  resultIdx := 0;
  tokenPos := 1;
  for i := 1 to lastDelimiterPos do
    if aString[i] = aDelimiter then
    begin
      SetString(Result[resultIdx], PChar(@aString[tokenPos]), i - tokenPos);
      tokenPos := i + 1;
      Inc(resultIdx);
    end;

  SetString(Result[resultIdx], PChar(@aString[tokenPos]), inputLen - lastDelimiterPos);
end;

 

Edited by Stefan Glienke
  • Like 1
  • Thanks 1

Share this post


Link to post
49 minutes ago, Stefan Glienke said:

Some of your functions have a defect as in returning an empty array when no delimiter is found - they must return a 1 element array with the input string if they should follow RTL behavior.

Also you can remove some unnecessary branching and make the code simpler:

Interesting how you used lastDelimiterPos. Pretty cool!

You are right, if no delimiters are found, then input string is the 1 string that needs to be returned.

 

Thanks!

Share this post


Link to post
function Split(const Str: string; const Delim: string; AllowEmpty: Boolean; LastIdx: Integer): TStrArray;
var CurrDelim, NextDelim, CurrIdx: Integer;
begin
  if Str = '' then begin SetLength(Result, 0); Exit; end;
  CurrDelim := 1; CurrIdx := 0; SetLength(Result, 16);

  repeat
    if CurrIdx = Length(Result) then
      SetLength(Result, CurrIdx + 16);           // check if array if full and extend if needed

    if (LastIdx <> -1) and (CurrIdx = LastIdx) then // last index reached - write all up to end
    begin
      NextDelim := Length(Str)+1;
    end
    else
    begin
      NextDelim := Pos(Delim, Str, CurrDelim);  // next delim
      if NextDelim = 0 then                        // string is finished - write all up to end
        NextDelim := Length(Str)+1;
    end;
    Result[CurrIdx] := Copy(Str, CurrDelim, NextDelim - CurrDelim);
    CurrDelim := NextDelim + Length(Delim);

    // if fragment is not empty or empty are OK - inc index
    if (Result[CurrIdx] <> '') or AllowEmpty
      then Inc(CurrIdx)
      else Continue;
  until CurrDelim > Length(Str);

  SetLength(Result, CurrIdx);                      // cut the array down
end;

- any string as delimiter

- customize if empty elements will be extracted ("foo;;;bar" & AllowEmpty=False => ["foo", "bar"])

- optional stop splitting after N elements found (mainly for name-value pairs like "login: user:pass" & LastIdx=1 => ["login", "user:pass"])

Edited by Fr0sT.Brutal
  • Like 1
  • Thanks 1

Share this post


Link to post
48 minutes ago, Fr0sT.Brutal said:

- any string as delimiter

- customize if empty elements will be extracted ("foo;;;bar" & AllowEmpty=False => ["foo", "bar"])

- optional stop splitting after N elements found (mainly for name-value pairs like "login: user:pass" & LastIdx=1 => ["login", "user:pass"])

Very good!

 

Why did you choose that LastIdx should be -1 and not 0, to parse all text?

 

If you pass 0 it will not delimit string. So I guess MaxInt is best, if you want all delimited strings?

If I call Split(str, delim, false, 0) it doesn't delimit, it only delimits full string with Split(str, delim, false, MaxInt), because of condition :

 

 if (LastIdx <> -1) and (CurrIdx = LastIdx) then // last index reached - write all up to end
    begin
      NextDelim := Length(Str)+1;
    end

and CurrIdx = 0 at the beginning.

 

I would expect: LastIdx = 0 => parse All, LastIdx >0 parse to that LastIdx and end. No?

  • Thanks 1

Share this post


Link to post
49 minutes ago, Mike Torrettinni said:

Why did you choose that LastIdx should be -1 and not 0, to parse all text?

Well, for values that accept anything >= 0, "-1" is short "default"/"not set" magic constant. It's not intended for use in code anyway because it's declared as default parameter (in decl section that I didn't copy). I guess you're right and MaxInt constant will do better; moreover it will eliminate one condition in code. And it's aligned to parameters of Copy(). Nice note!

Share this post


Link to post
12 hours ago, Attila Kovacs said:

@Fr0sT.Brutal cool, this also helped me to spot some bugs in my enum version. do you have a unit test for it?

Yep, here it is (on DUnitX facilities)

TStrArray is array<string> and DefListDelim is ';'


// Split/Join/GetElement

procedure TTest_Utils.TestSplit;

var
  arr: TStrArray;

  procedure CheckEquals(const CompArr: array of string; const TestDescr: string);
  var i: Integer;
  begin
    Assert.AreEqual(Integer(Length(CompArr)), Integer(Length(arr)), TestDescr + ' check lengths');
    for i := 0 to Length(CompArr) - 1 do
      Assert.AreEqual(CompArr[i], arr[i], TestDescr + ' compare items #'+IntToStr(i));
  end;

begin
  arr := Split('');
  CheckEquals([], 'Empty');

  arr := Split('qq');
  CheckEquals(['qq'], 'Single');

  arr := Split('qq;ww;ee');
  CheckEquals(['qq','ww','ee'], 'Normal');

  arr := Split('qq;ww;ee;');
  CheckEquals(['qq','ww','ee'], 'Sep on end');

  arr := Split('qq;ww;;;ee', DefListDelim, True);
  CheckEquals(['qq','ww','','','ee'], 'Empty items -- allow');

  arr := Split('qq;ww;;;ee', DefListDelim, False);
  CheckEquals(['qq','ww','ee'], 'Empty items -- deny');

  arr := Split('qq==ww==ee=', '==');
  CheckEquals(['qq','ww','ee='], 'Multichar sep');

  arr := Split('qq;ww;"ee;ll"', ';', True);
  CheckEquals(['qq','ww','"ee','ll"'], 'LastIdx 1');

  arr := Split('qq;ww;"ee;ll"', ';', True, 0);
  CheckEquals(['qq;ww;"ee;ll"'], 'LastIdx 1');

  arr := Split('qq;ww;"ee;ll"', ';', True, 1);
  CheckEquals(['qq','ww;"ee;ll"'], 'LastIdx 1');

  arr := Split('qq;ww;"ee;ll"', ';', True, 2);
  CheckEquals(['qq','ww','"ee;ll"'], 'LastIdx 2');
end;

 

  • Thanks 1

Share this post


Link to post

interesting how Split() interpretations differ:

 

  //Delphi RTL

  sp := 'qq;ww;ee;'.Split([';']); // => ['qq', 'ww', 'ee']

 

  // My test case

  arr := Split('qq;ww;ee;'); // => ['qq', 'ww', 'ee', '']

 

This in python and in php returning also ['qq', 'ww', 'ee', ''], and so would I expect.

 

 

Share this post


Link to post
16 minutes ago, Attila Kovacs said:

interesting how Split() interpretations differ:

Well, I thought hard on this nuance as well. Vote "for" is when string is constructed by dumb but simple loop `s := s + part + sep`, it will contain last sep that actually doesn't designate an element. Vote "against" is logic: if there's a separator - then an element must follow.

Share this post


Link to post

@Attila Kovacs

yep that's why I came up to AllowEmpty option. Nevertheless I agree that behavior should be consistent between implementations in other languages (this issue is long-lasting TODO entry, probably I'll move it somewhat upper)

  • Like 1

Share this post


Link to post
On 2/25/2021 at 9:39 AM, Attila Kovacs said:

interesting how Split() interpretations differ:

 

  //Delphi RTL

  sp := 'qq;ww;ee;'.Split([';']); // => ['qq', 'ww', 'ee']

 

  // My test case

  arr := Split('qq;ww;ee;'); // => ['qq', 'ww', 'ee', '']

 

This in python and in php returning also ['qq', 'ww', 'ee', ''], and so would I expect.

 

 

you must be using a version before 10.3 - this has been fixed: https://quality.embarcadero.com/browse/RSP-11302

  • Like 2
  • Thanks 1

Share this post


Link to post

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
Sign in to follow this  

×