Skip to content

Use hardware-accelerated AES CryptoServiceProvider - #865

Merged
WojciechNagorski merged 25 commits into
sshnet:developfrom
zybexXL:feature_AES_CSP
Nov 28, 2023
Merged

Use hardware-accelerated AES CryptoServiceProvider#865
WojciechNagorski merged 25 commits into
sshnet:developfrom
zybexXL:feature_AES_CSP

Conversation

@zybexXL

@zybexXLzybexXL commented Aug 30, 2021

Copy link
Copy Markdown
Contributor

This feature reduces CPU usage dramatically, allowing higher SFTP performance on slower machines. It does so by using the AesCryptoServiceProvider for AES CBC/CTR/ECB encryption/decryption, which taps into AES-NI accelerated OS functions.

STFP download performance testcases:

Test case A: 4 year-old i5-7300U 2-core/4-threads laptop, 250Mbps internet connection:

  • Before: 13.15MB/sec, one CPU thread fully saturated
  • After: 24.50 MB/sec (+86%), limited by internet connection, main thread at around 20-30% (5-10% in Task Manager)

Test case B: 4 year old I7-7700 4-core/8-threads, 500Mbps internet connection:

  • Before: 40.85 MB/sec, one CPU-thread fully saturated
  • After: 66.70 MB/sec (+63%), limited by internet connection, main thread at around 30-40% (5-10% in Task Manager)

Note: all values above were taken with PR #866 also merged in. Tested with sftpClient.DownloadFile()

SSH.NET vs AES-NI benchmarks

SSH.NET implements AES as pure managed code. CPUs nowadays have hardware acceleration for AES via the AES-NI instruction set, and .NET provides APIs to the OS functions that make use of it. There are 3 .NET providers for AES:

AesManaged : older managed-only implementation, not accelerated. Still faster than the current SSH.Net
AesCryptoServiceProvider: call into the OS and makes use of AES-NI if available
AesCNG (CryptoNewGen): newer Crypto API, available since 2018 in Win10. Also makes use of AES-NI and has less overhead

Here are some benchmarks (executed on the dual-core laptop...):

Provider Mode Iterations Average
---------- ---- --------------------------------------- ------------
SSH.NET CTR 29.44 29.75 29.41 29.81 28.69 => 29.42 MB/s
SSH.NET CBC 24.72 28.47 28.34 28.59 28.81 => 27.79 MB/s
SSH.NET ECB 33.75 33.50 25.34 27.56 23.53 => 28.74 MB/s
SSH.NET CFB 27.81 26.41 27.41 27.69 27.56 => 27.38 MB/s
SSH.NET OFB 27.97 28.06 27.75 27.69 27.75 => 27.84 MB/s
AesManaged CBC 41.19 41.50 41.72 41.78 25.66 => 38.37 MB/s
AesCSP CBC 717.06 777.25 828.03 815.72 812.75 => 790.16 MB/s (AES-NI)
AesCNG CBC 830.16 847.25 852.72 853.06 832.78 => 843.19 MB/s (AES-NI)

As you can see, the accelerated APIs are about 25x faster than the current managed code!

Since AesCNG is too new I chose to use AesCryptoServiceProvider. This supports CBC and ECB natively, but not CFB or OFB (which are not listed in the SSH.Net protocol list anyway). For CTR (used by AWS SFTP Transfer Family), my code uses ECB followed by XOR. This would be extremely fast on C/C++ (or using unsafe tag in C# to allow memory pointers), but on pure C# I had to do a few memory Blockcopy to convert between byte[] and uint[], so the result is not ideal - however, it's still about 10x faster than the previous code.

Here's the new AES benchmarks with this PR:

Provider Mode Iterations Average
---------- ---- --------------------------------------- ------------
SSH.NET+ CTR 316.53 317.69 315.72 305.00 308.53 => 312.69 MB/s (1063%)
SSH.NET+ CBC 768.03 744.16 772.19 781.16 761.41 => 765.39 MB/s (2754%)
SSH.NET+ ECB 2368.78 2370.50 2393.44 2368.03 2344.53 => 2369.06 MB/s (8243%)
SSH.NET+ CFB 27.31 27.78 26.91 27.81 26.66 => 27.29 MB/s (100%)
SSH.NET+ OFB 28.41 27.91 28.25 28.38 27.97 => 28.18 MB/s (101%)

These CTR and CBC values are now the new maximum theoretical SFTP performance of SSH.NET 😎

@jjxtra

Copy link
Copy Markdown

Well that's pretty good!

@IgorMilavec

Copy link
Copy Markdown
Collaborator

CTRArrayXOR can be optimized on supported platforms with the use of Vector<T>. It's faster and allocation free:

MethodSizeMeanErrorStdDevGen 0Allocated
Original128123.07 ns2.455 ns3.823 ns0.0968304 B
Vector12821.87 ns0.101 ns0.090 ns--
Original256184.98 ns1.866 ns1.745 ns0.1783560 B
Vector25638.55 ns0.096 ns0.085 ns--
Original512349.29 ns10.298 ns30.363 ns0.34141,072 B
Vector51281.09 ns0.251 ns0.235 ns--

Here is the code I used:

byte[]CTRArrayXOR(byte[]counter,byte[]data,intoffset,intlength){for(intloopOffset=0;length>0;length-=Vector<byte>.Count){varv=newVector<byte>(counter,loopOffset)^newVector<byte>(data,offset+loopOffset);if(length>=Vector<byte>.Count){v.CopyTo(counter,loopOffset);loopOffset+=Vector<byte>.Count;}else{for(inti=0;i<length;i++){counter[loopOffset++]=v[i];}}}returncounter;}

@zybexXL

zybexXL commented Sep 9, 2021

Copy link
Copy Markdown
ContributorAuthor

I've tested your snippet by replacing the XOR on my AES-CTR benchmark, which processes chunks of 32KB of random data (similar to what happens when downloading a file)
My current code: ~300MB/sec
Your code: ~150MB/sec

Note that my code copies the byte[] to uint[] so that the XOR is not done byte by byte. Does your benchmark take that into account? Is the "original" my PR code (accelerated), or the existing SSH-NET code?

You mention that vector<T> doesn't need to allocate memory... I think that's not exactly true. That FOR loop instantiates 2 Vector<byte> objects, each holding 4 bytes. Due to the limited scope the objects are immediately discarded after each loop. So it's still allocating 16384 objects in the stack in that FOR loop to XOR the two 32KB arrays. Instantiating objects is not free.

I see that vector<T> is likely optimized by the compiler to use CPU registers. Even so, it seems slower than my current code.

@zybexXL

zybexXL commented Sep 9, 2021

Copy link
Copy Markdown
ContributorAuthor

I recompiled the benchmark for NetCore 5.0 and added 3 versions of CTR:

  • BlockCopy (current code in this PR, copying byte[] to uint[] before XOR)
  • Vector<T> (IgorMilavec's suggestion)
  • Span<T> (allowing cast from byte[] to uint[] without memcopy)

Running it as Release instead of Debug also kicked performance by a lot, and changes the ranking. So here are the results:

BenchmarkAverage
SSH.NET CTR 2020.0.149.06 MB/s
SSH.NET CTR BlockCopy586.66 MB/s
SSH.NET CTR Span<T>671.85 MB/s
SSH.NET CTR Vector<T>731.97 MB/s
AesCSP CBC785.79 MB/s

So Vector is indeed the fastest, but available only since NetStandard 2.1 (like Span). Blockcopy is not that bad considering it's available for all platforms, and it's still 13x faster than base. We can add a separate PR with Vector/Span later on if needed.

Here's the snippet for SpanXOR - it assumes the arrays are 4-byte aligned for simplification:

privatebyte[]SpanXOR(byte[]data,intoffset,byte[]output){intuOffset=offset/4;Span<uint>uData=MemoryMarshal.Cast<byte,uint>(data);Span<uint>uOut=MemoryMarshal.Cast<byte,uint>(output);for(inti=0;i<uOut.Length;i++)uOut[i]=uOut[i]^uData[i+uOffset];returnoutput;}

@IgorMilavecIgorMilavec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@drieseng This looks fine. I've been using it in production for a couple of months without any side effects. With 10x performance improvement, I propose to merge this before the next release.

@A9G-Data-Droid

Copy link
Copy Markdown

I have tested this and it works!

Pedro Fonseca added 5 commits November 1, 2023 15:07
Reduces CPU usage dramatically, allowing more performance on slower machines
Fix padding for non-AES blockciphers
Fix IV exception for non-AES blockciphers
It looks like the legacy code doesn't correctly remove padding, so this code needs to do the same.
restructure AES CSP code into its own class
@zybexXL

Copy link
Copy Markdown
ContributorAuthor

I've rebased this PR, please review and consider merging.
I have been using this code since 2021 and I still see the huge performance gains this provides, together with #866

@Rob-Hague

Copy link
Copy Markdown
Collaborator

I'm very much in favour of this change, but I think the design needs some work. In fact, I think it can be much simpler as a starting point: I think we can just replace the implementation of AesCipher with an ICryptoTransform in ECB mode and no padding.

That way, we still use the given CipherMode object (rather than subverting it) which is more predictable, testable and yet we should still get the majority of the performance gains. The CipherModes can easily be optimized as a next step, and then further down the line (if we are still not satisfied) we can think about increasing the complexity in order to delegate to the BCL for the cipher modes too.

If you have any thoughts on this approach, or would like to work on it, let me know. Otherwise, I will play with it.

@zybexXL

Copy link
Copy Markdown
ContributorAuthor

All AES tests pass (including the new ones from #1232). The failed test is unrelated, seems spurious:

Failed CanWriteShouldReturnFalse [9 ms]
Error Message:
Initialization method Renci.SshNet.Tests.Classes.Sftp.SftpFileStreamTest_CanWrite_Closed_FileAccessReadWrite.SetUp threw exception. System.ArgumentOutOfRangeException: Cannot be less than or equal to zero. (Parameter 'bufferSize').

Is there a way to force re-run of the test suite without doing a dummy commit?

@WojciechNagorski

Copy link
Copy Markdown
Collaborator

I've merged #1232 can you update this PR and check if all tests passed?

@zybexXL

Copy link
Copy Markdown
ContributorAuthor

DST messed up the order of comments, my comment above was actually made after Wojciech's.
AES tests passed.

@zybexXL

zybexXL commented Nov 5, 2023

Copy link
Copy Markdown
ContributorAuthor

I'm very much in favour of this change, but I think the design needs some work. In fact, I think it can be much simpler as a starting point: I think we can just replace the implementation of AesCipher with an ICryptoTransform in ECB mode and no padding.

That way, we still use the given CipherMode object (rather than subverting it) which is more predictable, testable and yet we should still get the majority of the performance gains. The CipherModes can easily be optimized as a next step, and then further down the line (if we are still not satisfied) we can think about increasing the complexity in order to delegate to the BCL for the cipher modes too.

If you have any thoughts on this approach, or would like to work on it, let me know. Otherwise, I will play with it.

@Rob-Hague
ECB by itself is not enough. My main goal with this code was to improve performance with AWS SFTP, which uses CTR.
When I added this PR 3 years ago, SSH.Net still included many legacy frameworks which did not support the accelerated AESCryptoProvider API, so I left the legacy/slow ECB code in place and surrounded the new code with the FEATURE_AES_CSP conditional.

With the removal of the legacy frameworks perhaps we can get rid of the legacy code and just use this new one. However, I think we should first merge this in, and then rework and remove the legacy code in a separate PR.

@Rob-Hague

Copy link
Copy Markdown
Collaborator

With the removal of the legacy frameworks perhaps we can get rid of the legacy code and just use this new one. However, I think we should first merge this in, and then rework and remove the legacy code in a separate PR.

Let's get it right in this PR.

My biggest concern is the subversion of logic. When passing a CipherMode object, my expectation is that this object is going to be used in the cipher process (as it always has done). But with this change, the object is only used as a type check and the implementation is not touched. This is unexpected.

I have run some experiments with the following changes to the benchmarks:

Details
diff --git a/test/Renci.SshNet.Benchmarks/Security/Cryptography/Ciphers/AesCipherBenchmarks.cs b/test/Renci.SshNet.Benchmarks/Security/Cryptography/Ciphers/AesCipherBenchmarks.cs
index ff414cc4..b91e3b8a 100644
--- a/test/Renci.SshNet.Benchmarks/Security/Cryptography/Ciphers/AesCipherBenchmarks.cs+++ b/test/Renci.SshNet.Benchmarks/Security/Cryptography/Ciphers/AesCipherBenchmarks.cs@@ -15,7 +15,7 @@ namespace Renci.SshNet.Benchmarks.Security.Cryptography.Ciphers
{
_key = new byte[32];
_iv = new byte[16];
- _data = new byte[256];+ _data = new byte[32 * 1024];
Random random = new(Seed: 12345);
random.NextBytes(_key);
@@ -34,5 +34,29 @@ namespace Renci.SshNet.Benchmarks.Security.Cryptography.Ciphers
{
return new AesCipher(_key, new CbcCipherMode(_iv), null).Decrypt(_data);
}
++ [Benchmark]+ public byte[] Encrypt_CFB()+ {+ return new AesCipher(_key, new CfbCipherMode(_iv), null).Encrypt(_data);+ }++ [Benchmark]+ public byte[] Decrypt_CFB()+ {+ return new AesCipher(_key, new CfbCipherMode(_iv), null).Decrypt(_data);+ }++ [Benchmark]+ public byte[] Encrypt_CTR()+ {+ return new AesCipher(_key, new CtrCipherMode(_iv), null).Encrypt(_data);+ }++ [Benchmark]+ public byte[] Decrypt_CTR()+ {+ return new AesCipher(_key, new CtrCipherMode(_iv), null).Decrypt(_data);+ }
}
}

Results on develop branch 826222f:

MethodMeanErrorStdDevGen0Allocated
Encrypt_CBC320.4 μs0.47 μs0.36 μs15.625032.41 KB
Decrypt_CBC352.3 μs2.37 μs1.85 μs15.625032.41 KB
Encrypt_CFB335.6 μs0.43 μs0.34 μs15.625032.45 KB
Decrypt_CFB367.9 μs4.23 μs3.95 μs15.625032.45 KB
Encrypt_CTR310.4 μs0.38 μs0.31 μs15.625032.45 KB
Decrypt_CTR315.3 μs3.96 μs3.70 μs15.625032.45 KB

Results on my suggestion to replace the AesCipher implementation with the BCL Rob-Hague@8619062

MethodMeanErrorStdDevGen0Allocated
Encrypt_CBC221.2 μs2.01 μs1.88 μs15.869132.7 KB
Decrypt_CBC220.0 μs2.73 μs2.56 μs15.869132.71 KB
Encrypt_CFB251.3 μs0.33 μs0.27 μs15.625032.75 KB
Decrypt_CFB255.3 μs0.67 μs0.60 μs15.625032.75 KB
Encrypt_CTR201.3 μs0.41 μs0.38 μs15.869132.75 KB
Decrypt_CTR209.3 μs0.22 μs0.19 μs15.869132.75 KB

Results on your branch zybexXL@a9f68fb

MethodMeanErrorStdDevGen0Allocated
Encrypt_CBC29.637 μs0.1278 μs0.1067 μs16.113333.14 KB
Decrypt_CBC6.579 μs0.0160 μs0.0142 μs16.128533.14 KB
Encrypt_CFB333.761 μs0.1710 μs0.1335 μs15.625032.52 KB
Decrypt_CFB333.899 μs0.2090 μs0.1955 μs15.625032.52 KB
Encrypt_CTR24.630 μs0.0327 μs0.0273 μs78.1250161.2 KB
Decrypt_CTR24.544 μs0.0817 μs0.0683 μs78.1250161.2 KB

So my suggestion gets moderate gains but clearly we can do a lot better with an approach to delegate the entire encryption to the BCL (rather than block-by-block).

So here is what I propose:

We keep the behaviour of the existing constructor on AesCipher as is. That is, if you pass a CipherMode and/or CipherPadding object then they will be used as expected, with encryption happening block-by-block using the BCL as in my branch.

We add a new constructor which will allow delegating to the BCL for the entire encryption process. This will achieve the much better performance and separation from the existing behaviour.

publicAesCipher(System.Security.Cryptography.CipherMode cipherMode,System.Security.Cryptography.PaddingMode paddingMode,bool ctrMode = false){}

Since S.S.C.CipherMode does not have a CTR value, there is a ctrMode parameter. The constructor would throw if ctrMode is true and cipherMode != ECB. A bit awkward, but it should work fine. Other ideas would be to define our own CipherMode enum which does contain a CTR value, or to allow passing e.g. (S.S.C.CipherMode)-1 which we interpret as CTR.

In terms of the design, we define nested classes in AesCipher which represent the different implementations. Essentially, AesCipher becomes:

publicsealedclassAesCipher:BlockCipher{privatereadonlyBlockCipher_impl;publicAesCipher(byte[]key,CipherModemode,CipherPaddingpadding):base(key,16,mode,padding){_impl=newSshNetCipherModeImpl(key,mode,padding);}publicAesCipher(byte[]key,byte[]iv,System.Security.Cryptography.CipherModecipherMode,System.Security.Cryptography.PaddingModepaddingMode,boolctrMode=false){_impl=newBclImpl(/* ... */);}// AesCipher just forwards all implementation to _implpublicoverrideintEncryptBlock(byte[]inputBuffer,intinputOffset,intinputCount,byte[]outputBuffer,intoutputOffset){return_impl.EncryptBlock(inputBuffer,inputOffset,inputCount,outputBuffer,outputOffset);}publicoverrideintDecryptBlock(byte[]inputBuffer,intinputOffset,intinputCount,byte[]outputBuffer,intoutputOffset){return_impl.DecryptBlock(inputBuffer,inputOffset,inputCount,outputBuffer,outputOffset);}publicoverridebyte[]Encrypt(byte[]input,intoffset,intlength){return_impl.Encrypt(input,offset,length);}publicoverridebyte[]Decrypt(byte[]input){return_impl.Decrypt(input);}publicoverridebyte[]Decrypt(byte[]input,intoffset,intlength){return_impl.Decrypt(input,offset,length);}// Implementations// The block-by-block implementation using instantiated SshNet.CipherMode, SshNet.CipherPadding objectsprivatesealedclassSshNetCipherModeImpl:BlockCipher{privatereadonlyAes_aes;privateICryptoTransform_encryptor;privateICryptoTransform_decryptor;publicSshNetCipherModeImpl(byte[]key,CipherModemode,CipherPaddingpadding):base(key,16,mode,padding){// Initialise _aes in ECB mode}publicoverrideintEncryptBlock(byte[]inputBuffer,intinputOffset,intinputCount,byte[]outputBuffer,intoutputOffset){_encryptor??=_aes.CreateEncryptor();return_encryptor.TransformBlock(inputBuffer,inputOffset,inputCount,outputBuffer,outputOffset);}publicoverrideintDecryptBlock(byte[]inputBuffer,intinputOffset,intinputCount,byte[]outputBuffer,intoutputOffset){_decryptor??=_aes.CreateDecryptor();return_decryptor.TransformBlock(inputBuffer,inputOffset,inputCount,outputBuffer,outputOffset);}}privatesealedclassBclImpl:BlockCipher{// This overrides Encrypt/Decrypt for full BCL perf.publicoverridebyte[]Decrypt(byte[]input){thrownewNotImplementedException();}publicoverridebyte[]Decrypt(byte[]input,intoffset,intlength){thrownewNotImplementedException();}publicoverrideintDecryptBlock(byte[]inputBuffer,intinputOffset,intinputCount,byte[]outputBuffer,intoutputOffset){thrownewNotImplementedException();}publicoverridebyte[]Encrypt(byte[]input,intoffset,intlength){thrownewNotImplementedException();}publicoverrideintEncryptBlock(byte[]inputBuffer,intinputOffset,intinputCount,byte[]outputBuffer,intoutputOffset){thrownewNotImplementedException();}}}

Comments appreciated. Most important to me is that we do not subvert provided SshNet.CipherMode instances.

@Rob-Hague

Copy link
Copy Markdown
Collaborator

Most important to me is that we do not subvert provided SshNet.CipherMode instances.

One other thought: because the proposed new constructor is a bit awkward for CTR mode, I am in principle OK with subverting our own SshNet.CipherMode instances with an exact type check e.g. mode.GetType() == typeof(CtrCipherMode) instead of an is check (which would pass also for derived types which we definitely should not subvert). Then we can stick with one constructor which decides on the implementation, and the awkward logic is not exposed publicly.

This was referenced Aug 12, 2026
Sign up for freeto 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.

7 participants

@zybexXL@jjxtra@IgorMilavec@A9G-Data-Droid@Rob-Hague@WojciechNagorski@drieseng