Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce ARM Neon SIMD. #743

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft

Conversation

samyron
Copy link

@samyron samyron commented Feb 3, 2025

Version 2 of the introduction of ARM Neon SIMD.

There are currently two implementations:

  1. "Rules" based.
  2. Lookup Table based. This is effectively an SIMD accelerated version of the scalar implementation.

Benchmarks (Lookup table)

== Encoding mixed utf8 (5003001 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
                json    62.000 i/100ms
          json_coder    67.000 i/100ms
                  oj    30.000 i/100ms
Calculating -------------------------------------
                json    628.035 (±12.7%) i/s    (1.59 ms/i) -      3.162k in   5.118636s
          json_coder    626.843 (±15.8%) i/s    (1.60 ms/i) -      3.082k in   5.079836s
                  oj    352.174 (± 9.4%) i/s    (2.84 ms/i) -      1.740k in   5.005929s

Comparison:
                json:      628.0 i/s
          json_coder:      626.8 i/s - same-ish: difference falls within error
                  oj:      352.2 i/s - 1.78x  slower


== Encoding mostly utf8 (5001001 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
                json    50.000 i/100ms
          json_coder    56.000 i/100ms
                  oj    36.000 i/100ms
Calculating -------------------------------------
                json    632.784 (±27.0%) i/s    (1.58 ms/i) -      3.000k in   5.063991s
          json_coder    628.328 (±16.7%) i/s    (1.59 ms/i) -      3.080k in   5.034271s
                  oj    351.466 (± 9.7%) i/s    (2.85 ms/i) -      1.728k in   5.003977s

Comparison:
                json:      632.8 i/s
          json_coder:      628.3 i/s - same-ish: difference falls within error
                  oj:      351.5 i/s - 1.80x  slower

Benchmarks (Rules based)

== Encoding mixed utf8 (5003001 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
                json    69.000 i/100ms
          json_coder    78.000 i/100ms
                  oj    33.000 i/100ms
Calculating -------------------------------------
                json    758.135 (±22.7%) i/s    (1.32 ms/i) -      3.657k in   5.114664s
          json_coder    800.957 (±11.5%) i/s    (1.25 ms/i) -      3.978k in   5.044465s
                  oj    343.750 (±11.9%) i/s    (2.91 ms/i) -      1.683k in   5.004571s

Comparison:
                json:      758.1 i/s
          json_coder:      801.0 i/s - same-ish: difference falls within error
                  oj:      343.7 i/s - 2.21x  slower


== Encoding mostly utf8 (5001001 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
                json    59.000 i/100ms
          json_coder    53.000 i/100ms
                  oj    37.000 i/100ms
Calculating -------------------------------------
                json    828.807 (±15.1%) i/s    (1.21 ms/i) -      4.071k in   5.060739s
          json_coder    799.688 (±20.1%) i/s    (1.25 ms/i) -      3.816k in   5.019480s
                  oj    364.514 (± 7.1%) i/s    (2.74 ms/i) -      1.850k in   5.100773s

Comparison:
                json:      828.8 i/s
          json_coder:      799.7 i/s - same-ish: difference falls within error
                  oj:      364.5 i/s - 2.27x  slower

I am still working on this but I wanted to share progress.

Edit: Looks like I missed one commit so I'll have to resolve some merge conflicts.

@byroot
Copy link
Member

byroot commented Feb 3, 2025

The gain seem to be 7% on real word benchmarks:

== Encoding activitypub.json (52595 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
               after     2.438k i/100ms
Calculating -------------------------------------
               after     24.763k (± 0.8%) i/s   (40.38 μs/i) -    124.338k in   5.021560s

Comparison:
              before:    23166.2 i/s
               after:    24762.5 i/s - 1.07x  faster


== Encoding twitter.json (466906 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
               after   254.000 i/100ms
Calculating -------------------------------------
               after      2.600k (± 1.3%) i/s  (384.61 μs/i) -     13.208k in   5.080852s

Comparison:
              before:     2439.5 i/s
               after:     2600.0 i/s - 1.07x  faster

Also note that I did one more refactoring to make the introduction of SIMD easier, so you still have a conflict.

Comment on lines 20 to 26
uint8x16x4_t load_uint8x16_4(const unsigned char *table, int offset) {
uint8x16x4_t tab;
for(int i=0; i<4; i++) {
tab.val[i] = vld1q_u8(table+offset+(i*16));
}
return tab;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately it's not. vld4q_u8 interleaves the data among the 4 vector registers.

% cat load-test.c 
#include <stdio.h>
#include <stdint.h>
#include <arm_neon.h>

void print_vec(char *msg, uint8x16_t vec) {
  printf("%s\n[ ", msg);
  uint8_t store[16] = {0};
  vst1q_u8(store, vec);
  for(int i=0; i<16; i++) {
    printf("%3d ", store[i]);
  }
  printf("]\n");
}

uint8x16x4_t load_table(uint8_t *table, int offset) {
  uint8x16x4_t tab;
  for(int i=0; i<4; i++) {
    tab.val[i] = vld1q_u8(table+offset+(i*16));
  }
  return tab;
}

int main(void) {
  uint8_t table[256];
  
  for(int i=0; i<256; i++) {
    table[i] = i;
  }

  uint8x16x4_t tab1 = load_table(table, 0);

  print_vec("tab1.val[0]", tab1.val[0]);
  print_vec("tab1.val[1]", tab1.val[1]);
  print_vec("tab1.val[2]", tab1.val[2]);
  print_vec("tab1.val[3]", tab1.val[3]);

  printf("\n");
  uint8x16x4_t tab1_2 = vld4q_u8(table);
  print_vec("tab1_2.val[0]", tab1_2.val[0]);
  print_vec("tab1_2.val[1]", tab1_2.val[1]);
  print_vec("tab1_2.val[2]", tab1_2.val[2]);
  print_vec("tab1_2.val[3]", tab1_2.val[3]);
  
  return 0;
}
% ./load-test 
tab1.val[0]
[   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15 ]
tab1.val[1]
[  16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31 ]
tab1.val[2]
[  32  33  34  35  36  37  38  39  40  41  42  43  44  45  46  47 ]
tab1.val[3]
[  48  49  50  51  52  53  54  55  56  57  58  59  60  61  62  63 ]

tab1_2.val[0]
[   0   4   8  12  16  20  24  28  32  36  40  44  48  52  56  60 ]
tab1_2.val[1]
[   1   5   9  13  17  21  25  29  33  37  41  45  49  53  57  61 ]
tab1_2.val[2]
[   2   6  10  14  18  22  26  30  34  38  42  46  50  54  58  62 ]
tab1_2.val[3]
[   3   7  11  15  19  23  27  31  35  39  43  47  51  55  59  63 ]

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, that's so weird.

Well, maybe that loop should be unrolled then, I suspect the compiler does it, but might as well be explicit.

@byroot
Copy link
Member

byroot commented Feb 3, 2025

Can you just include the implementation for the regular escaping? I'm not sure the script safe version is quite worth it.

Comment on lines 392 to 398
if ((ch_len = search_escape_basic_neon_advance_lut(search)) != 0) {
return ch_len;
}

// if ((ch_len = search_escape_basic_neon_advance_rules(search)) != 0) {
// return ch_len;
// }
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like it's a toss up which one is the best. It might be an artifact that my M1 Macbook Air is passively cooled and it gets warm after I run it over and over.

@samyron
Copy link
Author

samyron commented Feb 6, 2025

Comparison between master and this branch in real world benchmarks. This is for the lookup table implementation.

== Encoding activitypub.json (52595 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after     2.027k i/100ms
Calculating -------------------------------------
               after     21.413k (± 1.6%) i/s   (46.70 μs/i) -    107.431k in   5.018339s

Comparison:
              before:    14448.8 i/s
               after:    21412.9 i/s - 1.48x  faster


== Encoding citm_catalog.json (500298 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after   110.000 i/100ms
Calculating -------------------------------------
               after      1.098k (± 1.2%) i/s  (910.41 μs/i) -      5.500k in   5.007977s

Comparison:
              before:      993.9 i/s
               after:     1098.4 i/s - 1.11x  faster


== Encoding twitter.json (466906 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after   216.000 i/100ms
Calculating -------------------------------------
               after      2.086k (± 8.9%) i/s  (479.31 μs/i) -     10.368k in   5.034983s

Comparison:
              before:     1642.1 i/s
               after:     2086.3 i/s - 1.27x  faster

Running it a second time:

== Encoding activitypub.json (52595 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after     2.042k i/100ms
Calculating -------------------------------------
               after     21.400k (± 1.7%) i/s   (46.73 μs/i) -    108.226k in   5.058877s

Comparison:
              before:    15039.4 i/s
               after:    21399.7 i/s - 1.42x  faster


== Encoding citm_catalog.json (500298 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after   109.000 i/100ms
Calculating -------------------------------------
               after      1.094k (± 1.2%) i/s  (913.67 μs/i) -      5.559k in   5.079778s

Comparison:
              before:     1005.4 i/s
               after:     1094.5 i/s - 1.09x  faster


== Encoding twitter.json (466906 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after   215.000 i/100ms
Calculating -------------------------------------
               after      2.137k (± 5.5%) i/s  (467.91 μs/i) -     10.750k in   5.050467s

Comparison:
              before:     1639.0 i/s
               after:     2137.1 i/s - 1.30x  faster

…e only need 128 bytes for the lookup table as the top 128 bytes are all zeros.
@byroot
Copy link
Member

byroot commented Feb 6, 2025

Not sure why but it's way more modest on my machine (Air M3):

== Encoding activitypub.json (52595 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
               after     2.603k i/100ms
Calculating -------------------------------------
               after     26.544k (± 1.8%) i/s   (37.67 μs/i) -    132.753k in   5.002890s

Comparison:
              before:    23370.1 i/s
               after:    26543.7 i/s - 1.14x  faster


== Encoding citm_catalog.json (500298 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
               after   136.000 i/100ms
Calculating -------------------------------------
               after      1.368k (± 0.7%) i/s  (730.98 μs/i) -      6.936k in   5.070329s

Comparison:
              before:     1369.9 i/s
               after:     1368.0 i/s - same-ish: difference falls within error


== Encoding twitter.json (466906 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
               after   269.000 i/100ms
Calculating -------------------------------------
               after      2.702k (± 0.3%) i/s  (370.11 μs/i) -     13.719k in   5.077550s

Comparison:
              before:     2475.0 i/s
               after:     2701.9 i/s - 1.09x  faster

@samyron
Copy link
Author

samyron commented Feb 10, 2025

Apologies for going dark for a while. I've been trying to make incremental improvements on a different branch (found here). My hope was using a move mask would be faster than vmaxvq_u8 to determine if any byte needs to be escaped. It also has the benefit of not needing to store all of the candidate matches as all that would be needed is a uint64_t which indicates which bytes need to be escaped. Unfortunately on my machine, it didn't seem to make much of a difference.

Feel free to try it out though.

@byroot
Copy link
Member

byroot commented Feb 10, 2025

Apologies for going dark for a while

That's no worries at all. I want to release a 2.10.0 with the current change on master, but I'm pairing with Étienne on making sure we have no blind spots on JSON::Coder. So probably gonna happen this week.

After that I think I can start merging some SIMD stuff. I'd like to go with the smaller possible useful SIMD acceleration to ensure it doesn't cause issues with people. If it works well, we can then go farther. So yeah, no rush.

@samyron
Copy link
Author

samyron commented Feb 11, 2025

@byroot if you have a few minutes, would you be able to checkout this branch and benchmark it against master. You'll have to tweak your compare script a bit to compile this branch with cmd("bundle", "exec", "rake", "clean", "compile", "--", "--disable-generator-use-simd"). I want to see how your M3 compares with my M1.

This branch uses the bit twiddling sort of platform agnostic SIMD code if the SIMD code is disabled via aextconf.rb flag.

The results on my M1:

== Encoding activitypub.json (52595 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after     1.944k i/100ms
Calculating -------------------------------------
               after     19.671k (± 2.5%) i/s   (50.84 μs/i) -     99.144k in   5.043309s

Comparison:
              before:    15135.7 i/s
               after:    19670.9 i/s - 1.30x  faster


== Encoding citm_catalog.json (500298 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after   113.000 i/100ms
Calculating -------------------------------------
               after      1.109k (± 2.1%) i/s  (901.49 μs/i) -      5.650k in   5.095561s

Comparison:
              before:     1040.1 i/s
               after:     1109.3 i/s - 1.07x  faster


== Encoding twitter.json (466906 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after   204.000 i/100ms
Calculating -------------------------------------
               after      2.006k (± 3.8%) i/s  (498.51 μs/i) -     10.200k in   5.092718s

Comparison:
              before:     1687.4 i/s
               after:     2006.0 i/s - 1.19x  faster

@byroot
Copy link
Member

byroot commented Feb 12, 2025

With that compilation flag and compared to master:

== Encoding activitypub.json (52595 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
               after     2.326k i/100ms
Calculating -------------------------------------
               after     23.218k (± 1.6%) i/s   (43.07 μs/i) -    116.300k in   5.010271s

Comparison:
              before:    22460.3 i/s
               after:    23218.0 i/s - 1.03x  faster


== Encoding citm_catalog.json (500298 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
               after   132.000 i/100ms
Calculating -------------------------------------
               after      1.290k (± 1.4%) i/s  (775.38 μs/i) -      6.468k in   5.016121s

Comparison:
              before:     1323.6 i/s
               after:     1289.7 i/s - same-ish: difference falls within error


== Encoding twitter.json (466906 bytes)
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
               after   242.000 i/100ms
Calculating -------------------------------------
               after      2.495k (± 0.6%) i/s  (400.84 μs/i) -     12.584k in   5.044306s

Comparison:
              before:     2449.6 i/s
               after:     2494.8 i/s - 1.02x  faster

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants