Repository navigation
VM AOT does not recognize 32-bit rotate, so SHA-256 runs 1.7x faster under dart2js than native #64389
Description
The code below is a complete SHA-256. It checks the NIST abc vector, then hashes 64 MB and prints MB/s and the digest. The digest is the same on all three targets and matches sha256sum of the same bytes (52c5b272...f74736). The dart2js build on node is 1.7x faster than the AOT build.
import 'dart:typed_data';
const _k = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, //
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
int rotr(int x, int n) => ((x >>> n) | (x << (32 - n))) & 0xffffffff;
void compress(Uint32List h, Uint32List w, Uint8List d) {
for (var at = 0; at + 64 <= d.length; at += 64) {
for (var i = 0; i < 16; i++) {
final o = at + 4 * i;
w[i] = (d[o] << 24) | (d[o + 1] << 16) | (d[o + 2] << 8) | d[o + 3];
}
for (var i = 16; i < 64; i++) {
final x = w[i - 15], y = w[i - 2];
final s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
final s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10);
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
}
var a = h[0], b = h[1], c = h[2], e = h[4], f = h[5], g = h[6];
var dd = h[3], hh = h[7];
for (var i = 0; i < 64; i++) {
final s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
final t1 = (hh + s1 + ((e & f) ^ (~e & g)) + _k[i] + w[i]) & 0xffffffff;
final s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
final t2 = s0 + ((a & b) ^ (a & c) ^ (b & c));
hh = g;
g = f;
f = e;
e = (dd + t1) & 0xffffffff;
dd = c;
c = b;
b = a;
a = (t1 + t2) & 0xffffffff;
}
h[0] += a;
h[1] += b;
h[2] += c;
h[3] += dd;
h[4] += e;
h[5] += f;
h[6] += g;
h[7] += hh;
}
}
String sha256(Uint8List message) {
final length = message.length;
final whole = length & ~63;
final padded = Uint8List(length - whole < 56 ? 64 : 128);
padded.setRange(0, length - whole, message, whole);
padded[length - whole] = 0x80;
var bits = length * 8;
for (var i = padded.length - 1; i >= padded.length - 8; i--) {
padded[i] = bits % 256;
bits ~/= 256;
}
final h = Uint32List.fromList([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, //
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
final w = Uint32List(64);
compress(h, w, Uint8List.sublistView(message, 0, whole));
compress(h, w, padded);
return h.map((v) => v.toRadixString(16).padLeft(8, '0')).join();
}
void main() {
const abc =
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad';
if (sha256(Uint8List.fromList('abc'.codeUnits)) != abc) {
throw StateError('NIST vector failed');
}
final data = Uint8List(64 << 20);
for (var i = 0; i < data.length; i++) {
data[i] = (i * 31 + (i >> 11)) & 0xff;
}
for (var pass = 0; pass < 10; pass++) {
final sw = Stopwatch()..start();
final digest = sha256(data);
final s = sw.elapsedMicroseconds / 1e6;
print('${(data.length / 1e6 / s).toStringAsFixed(0)} MB/s $digest');
}
}Run:
dart compile exe sha_rotate.dart -o sha_vm && ./sha_vm
dart compile js -O2 sha_rotate.dart -o sha.js && node sha.js
dart compile wasm sha_rotate.dart -o sha.wasm && node run_wasm.mjsrun_wasm.mjs:
import { readFile } from 'node:fs/promises';
import { compile } from './sha.mjs';
const bytes = await readFile(new URL('./sha.wasm', import.meta.url));
const app = await compile(bytes);
const inst = await app.instantiate({});
inst.invokeMain();Results, 10 passes each, steady state after warm-up:
| target | MB/s |
|---|---|
dart compile exe (AOT) |
182-184 |
dart compile js -O2, node |
308-315 |
dart compile wasm, node |
187-190 |
Dart2js emits (x >>> n | x << (32 - n)) >>> 0, and V8 TurboFan turns it into Word32Ror in its EarlyOptimization phase. On arm64 it selects ror or folds the rotate into eor w, w, w, ror #n, with no separate shifts left for the rotates.
The AOT code for compress has no ror. Each rotr compiles to lsr + lsl + orr. That is 2 extra instructions per rotate, 1152 per 64-byte block (576 rotates).
dart compile aot-snapshot sha_rotate.dart -o sha.aot --verbose \
--extra-gen-snapshot-options=--disassemble-optimized \
--extra-gen-snapshot-options=--print-flow-graph-filter=compressIt is not arm64 only. With --target-os=linux --target-arch=x64 added to the same command, compress has no ror/rol either: 10 shrl + 10 shll + 10 orl for the 10 rotates in the source. I could not time x64 here. In runtime/vm/compiler/backend there is no rotate operation at all, so neither backend can emit one.
Could the VM compiler recognize ((x >>> n) | (x << (32 - n))) & 0xffffffff with a constant n, on a value known to fit in 32 bits, and emit a rotate?
Related: #6486 (rotate in the core library), #64345 (Int32x4.rotateRight).
Dart SDK 3.13.4 (stable) macos_arm64, macbook M4 Max, node v26.8.1
For comparison in C: a portable SHA-256 (clang -O2, no SHA instructions, 128 ror in the block function) hashes 100 MB at 634 MB/s. The same code with each rotate forced into lsr + lsl + orr (empty asm barriers between the shifts, 0 ror left) runs at 448 MB/s, 1.42x slower, with the same digest.
Activity
modulovalue commentedon Sep 25, 2026
I don't think that's a fair comparison then.
There are two angles here:
If you want to see a fast SHA-256, adding a rotate right is not really important. Adding dedicated SHA intrinsics would be more helpful.
If you want to show a reason for why rotate right is useful, this is a bad example because it doesn't make a big difference when it comes to reaching an optimal SHA-256 implementation.
So your argument is kind of telling me that we should probably not invest time in having a scalar rotate right as it wouldn't make much of a difference in reaching an optimal SHA-256 implementation.
Optimal SHA-256 vs our package:crypto SHA-256:
Side note: SHA-256 with SHA intrinsics reaches up to 3 GB/s on my machine (M5 Max) and 1 GB/s without.
krll-kov commentedon Sep 26, 2026
Thanks for testing this!
The Dart comparison in the issue is the same source on two compilers, neither using SHA instructions, so it seems fair: 182-184 MB/s AOT against 308-315 MB/s under dart2js.
The C code below is a control that changes one thing. It is the same loop as the Dart code, and clang emits 10 ror in compress, one for each rotate in the source, where Dart AOT emits lsr + lsl + orr. With -DNO_ROR each rotate is forced into those three instructions: X MB/s against Y MB/s on enwik8, Z x. Not all of the dart2js 1.7x is the rotate, since V8 also computes in Word32 without the & 0xffffffff masks, but Z x is the rotate alone.
X = 455 MB/s
Y = 336 MB/s
Z = 1.36x
Measured on enwik8 (100 MB), M4 Max, clang O2: 10 alternating runs of both builds, 5 passes each, best pass of each build; the top 8 passes of each build agree within 1 MB/s.
Both builds give the same digest as shasum -a 256. The 1 GB/s you get without SHA instructions is scalar code built on ror and eor ..., ror #n, so rotate is part of reaching that number, not beside it. SHA intrinsics would help SHA-256 only. xxHash, ChaCha20, BLAKE2/3, SipHash and Murmur have no dedicated instructions and depend on the same pattern that LLVM, GCC and V8 already recognize.
Previous C numbers in the issue body came from an unrolled version (8 rounds per iteration, 16-word rolling schedule, 128 ror in the block function): 634 against 448 MB/s, 1.42x. The code above is a plain loop that matches the Dart source, 10 ror, and shows the same effect at 1.36x.
modulovalue commentedon Sep 26, 2026
I'm on your side, rotate right would make things faster, definitely, but let's assume we had a rotate right over integers, then you might want a rotate left, then you might want some other permutation of bytes, a bit reverse and so on. If we add them to the integer interface we end up with dozens of methods that make the life of everyone harder. (If we don't add them to the integer interface, then where? A general purpose bit operation class with statics? What platforms do we support those on? Is this work that's worthwhile?)
I can think of two kinds of people who would benefit from those operations:
Consider for example a use case that needs POW for whatever reason, you're doing a Bitcoin style protection scheme where you need to find a SHA-256 with a specific suffix. Those are use cases where performance matters MOST and none of them would start using SHA-256 if we added a rotate right. Most other people who need SHA-256 are creating a checksum of small binary blobs and they wouldn't notice a faster SHA-256.
You might be suggesting this because we recently landed oneBitCount and trailingZeroBitCount. Those are immensely useful and allow us to move very close to an optimal implementation without leaving Dart (bitarray iteration and cardinality, succinct representations of trees, and many SIMD algorithms where we need CTZ for extracting the position of a bit in a signmask). But rotate right would not benefit many people and in cases where it really matters, I claim, it will be superseded by SIMD anyways.
To summarize, I think SHA-256 is not a good use case to show why rotate right matters. I think a scalar rotate right matters, just not enough that it's worth working on at the moment.
krll-kov commentedon Sep 26, 2026
I agree that demands do grow with each feature approval, and you're right overall. My point is that the language doesn't stand still, and operations like this are needed in many algorithms, not only SHA-256.
Yes, native code can be tens of times faster thanks to assembly and NEON. But people don't expect native speed from Dart. They just want the existing implementations not to be tens of times slower. Being 1.5-2x slower than native is perfectly acceptable for cross-platform code that is much easier to write than C.
Your argument about FFI is valid, but people don't want to use it: it has platform limitations, all those DLLs add a lot of weight, and it means maintaining endless bindings. That's exactly what makes Dart simple: everything can be implemented in plain Dart without any DLLs. It's just missing a few optimizations that the most widely used algorithms depend on.
Also, I'm not asking for a new method on int. The idea is that the compiler recognizes the pattern people already write, (x >>> n) | (x << (32 - n)) with a constant n, and emits a single rotate instruction, the way V8 and LLVM already do. Nothing is added to the API, so rotate left, bit reverse or a separate class of static helpers are not part of this.
Mentioned trailingZeroBitCount is also great, i myself already used it in zstd decoder/encoder and got a free speed increase with minor code rewrite
modulovalue commentedon Sep 26, 2026
I do.
It's not. That's not the standard I'd like Dart to have. That's not acceptable to me.
The Dart team and people like me, an external contributor, have to focus on things that matter and we need to implement them in ways that is scalable and makes sense. Adding a ton of peephole optimizations is dead weight if nobody is going to benefit much from them. I also would like to point you to the full employment theorem. There's an endless amount of optimization due to Gödel so the fact that an inefficiency is discovered is not really valuable in itself. The question is who is going to benefit from it.
There's a much more scalable approach to adding such peephole optimizations that we don't support yet (mainly because it's the state of the art and an active area of research): if we use tree automata (which in that area are also known as e-graphs) for specifying such patterns it would reduce down to a few lines of code where certain patterns could be optimally transduced into more efficient patterns. That's when adding such peephole optimizations would become much more valuable because we could also be taking algebraics into consideration #64286. We are unfortunately not there yet.
modulovalue commentedon Sep 26, 2026
Off topic, note to self: A heavily unrolled SHA-256 doesn't pass the code size limits and doesn't get optimized on JIT which is the same problem I've seen on huge generated lexers. We could probably raise those limits as I've done some work on eliminating obvious inefficiencies there. I'll try to investigate that once I look into the lexer use case again.
krll-kov commentedon Sep 26, 2026
Fair enough, if native speed is the goal, even better.
But you keep treating this as part of ton of peephole optimizations. It's one pattern, and LLVM, GCC and V8 already recognize it. Dart AOT is the outlier here: the same Dart source runs 1.7x faster under dart2js, and in the C control the rotate alone is worth 1.36x.
As for who benefits: most hashes and ciphers written in plain Dart, such as SHA-1/2/3, MD5, BLAKE2/3, ChaCha20, Salsa20, xxHash and SipHash, and KDFs built on them like PBKDF2, scrypt and Argon2, where the hash runs thousands of times per call, all use this pattern, same for package:crypto, pointycastle, cryptography. Code that already writes rotates this way, including the masked 32-bit forms, would get faster without changes. I never mentioned that something new in INT api should be added, it's not the place for such change.
E-graphs sound like the right long term design, and I'd be glad to see them. Until then, a single rotate pattern seems like a small change with a broad effect.
I'd also love to see 1:1 with native in the long term, but I'm not sure how to get there without turning a 300-line algorithm into 3-4k lines of manually unrolled loops and similar tricks. I've spent a lot of time reading the assembly Dart generates, and there are many places where it does work that isn't needed, many of them are tiny ones, but each tiny change looks like an unneeded one since it's just a few %, nobody for some reason thinks about cumulative effect which, taken together, yields not just a couple of percent, but steady growth.
biggs0125 commentedon Sep 30, 2026
This is a nice to have. Someone on the Dart team may choose to add the logic to recognize this pattern but this is not really at the top of our queue right now.
biggs0125 commentedon Sep 30, 2026
@lrhn Is this something that would be worth adding as an explicit API in somewhere like 'dart:math' (or int directly)?
lrhn commentedon Sep 30, 2026
If it's a 32-bit operation, I'd prefer to put it in
dart:typed_datain some way. That's where 32-bit numbers live.Maybe an
extension type Uint32(int _)with multiple 32-bit operations.But then the question becomes why there isn't a type for every number kind er have a typed data list for. And whether it should be a real type, not just an extension type, so it doesn't have to box intermediate results as an
intJust having a single 32-bit operation somewhere feels ad-hoc.