PyTorch vs C# (TorchSharp): Deep Learning on .NET 10
Last updated: November 6, 2026 · Tested with .NET 10.0.12 (SDK 10.0.401), TorchSharp 0.107.0 (libtorch 2.10.0), PyTorch 2.15.0, ONNX Runtime 1.31.1, Windows 11
Short answer: pick PyTorch (Python) for research, experiments and anything that needs the ecosystem: pretrained models, papers, GPU tooling. Pick C# with TorchSharp when a model has to train or run inside a .NET application and you don’t want a Python process next to it. Speed is not the deciding factor, because both run the same libtorch engine. I trained the same network on the same 60,000 samples in both: identical first-epoch loss (0.6494), median epoch 1,369 ms in C# vs 1,674 ms in Python. If you only need inference, ONNX Runtime in C# was fastest of all: 23.1 µs per sample.
| Measured on the same laptop (median of 5 runs) | TorchSharp 0.107.0 (C#) | PyTorch 2.15.0 (Python) |
|---|---|---|
| Training time per epoch (60,000 × 784, batch 128) | 1,369 ms | 1,674 ms |
| Loss after epoch 1 / final test accuracy | 0.6494 / 78.77% | 0.6494 / 79.02% |
| Inference, 1 sample | 101.1 µs | 160.8 µs |
| Inference, 10,000 samples in one batch | 22.38 ms | 21.28 ms |
| Peak working set, full training run | 679 MB | 533 MB |
| Process start to first tensor result | 504 ms | 1,745 ms |
| Download | 256.0 MB (TorchSharp-cpu), 79.4 MB Windows-only | 126.6 MB torch wheel |
| On disk | 1,013.7 MB build output, 268.6 MB published for win-x64 | 513.8 MB torch folder, 679.9 MB whole venv |
Machine: Intel Core Ultra 5 125U (12 cores, 14 threads), 15.5 GB RAM, Windows 11, running on battery. Both libraries used 12 threads. The single-sample numbers were noisy (40 to 204 µs between TorchSharp runs, 91 to 205 µs for PyTorch), so read that row as “same ballpark”, not as a win for C#.
The Test: One Model, Two Languages
I didn’t want a benchmark that compares a C# toy with a Python toy, so both programs do exactly the same work:
- Data: a synthetic, MNIST-sized set (60,000 training rows, 10,000 test rows, 784 float32 features, 10 classes) generated once by
make_data.pywith a fixed seed and written to raw.binfiles. Both programs read the same files, and nothing has to be downloaded. The labels come from a fixed random “teacher” network, so there is a real non-linear pattern, but the set tops out near 79% accuracy and starts to overfit after epoch 2. It’s a workload, not a leaderboard. - Model: an MLP, 784 → 256 → 128 → 10 with ReLU.
- Training: Adam, learning rate 0.001, batch size 128, 5 epochs, a fresh
randpermshuffle each epoch,manual_seed(42). - Measurements: a Python harness (
bench.py) ran every case 5 times, interleaved (run 1 of each case, then run 2, and so on) so heat and battery state hit both sides equally. Each program reports its own peak working set.
TorchSharp on .NET 10: The C# Version
The project file after dotnet add package TorchSharp-cpu:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TorchSharp-cpu" Version="0.107.0" />
</ItemGroup>
</Project>
That one reference is the easy route, and it has a cost; see the package size section below. The model is a class that derives from Module<Tensor, Tensor>:
using System.Diagnostics;
using TorchSharp;
using static TorchSharp.torch;
using static TorchSharp.torch.nn;
sealed class Mlp : Module<Tensor, Tensor>
{
private readonly Module<Tensor, Tensor> fc1 = Linear(784, 256);
private readonly Module<Tensor, Tensor> fc2 = Linear(256, 128);
private readonly Module<Tensor, Tensor> fc3 = Linear(128, 10);
public Mlp() : base(nameof(Mlp))
{
RegisterComponents(); // without this, parameters() is empty
}
public override Tensor forward(Tensor x)
{
using var h1 = functional.relu(fc1.call(x));
using var h2 = functional.relu(fc2.call(h1));
return fc3.call(h2);
}
}
RegisterComponents() is the line people forget. PyTorch finds submodules when you assign self.fc1; C# has no hook for that, so TorchSharp registers the fields by reflection when you call it. The field names become the state-dict keys (fc1.weight, fc1.bias, …), and that matters later when you load PyTorch weights.
The training loop:
using var model = new Mlp();
using var optimizer = torch.optim.Adam(model.parameters(), lr: 1e-3);
using var lossFn = CrossEntropyLoss();
long n = xTrain.shape[0];
for (int epoch = 1; epoch <= Epochs; epoch++)
{
model.train();
var sw = Stopwatch.StartNew();
using var perm = torch.randperm(n);
double lossSum = 0;
int batches = 0;
for (long start = 0; start < n; start += BatchSize)
{
// every tensor created in this block is freed at the closing brace
using var scope = useScope ? torch.NewDisposeScope() : null;
var idx = perm.slice(0, start, Math.Min(start + BatchSize, n), 1);
var xb = xTrain.index_select(0, idx);
var yb = yTrain.index_select(0, idx);
optimizer.zero_grad();
var loss = lossFn.call(model.call(xb), yb);
loss.backward();
optimizer.step();
lossSum += loss.item<float>();
batches++;
}
sw.Stop();
double acc = Train.Accuracy(model, xTest, yTest);
Console.WriteLine($"epoch {epoch} loss {lossSum / batches:F4} test acc {acc:P2} {sw.ElapsedMilliseconds} ms");
}
The useScope flag exists only so I could measure what happens without the dispose scope (a --no-scope argument turns it off). In your own code, just write using var scope = torch.NewDisposeScope();. Saving, reloading and inference:
model.save("TorchDemo/mlp.dat");
using var restored = new Mlp();
restored.load("TorchDemo/mlp.dat");
restored.eval();
using (torch.no_grad())
{
using var one = xTest.slice(0, 0, 1, 1);
using var first = restored.call(one).argmax(1);
Console.WriteLine($"sample 0: predicted {first.item<long>()}, label {yTest[0].item<long>()}");
}
A real run (run 3 of 5):
TorchSharp 0.107.0.0, threads 12
train 60000 x 784, test 10000
epoch 1 loss 0.6494 test acc 78.37% 1297 ms
epoch 2 loss 0.4835 test acc 79.19% 1269 ms
epoch 3 loss 0.4166 test acc 77.76% 1752 ms
epoch 4 loss 0.3562 test acc 79.15% 1976 ms
epoch 5 loss 0.2921 test acc 78.77% 1537 ms
reloaded model: test acc 78.77%, file 940673 bytes
sample 0: predicted 0, label 0
inference 1 sample: median 116.8 us
inference 10000 samples: median 18.64 ms
peak working set 680 MB
The reloaded model scores exactly what the trained one did, so save/load round-trips. The file is TorchSharp’s own format, not a PyTorch .pth.
The Same Model in PyTorch
Python 3.13 venv, CPU wheel from the official index (pip install torch numpy --index-url https://download.pytorch.org/whl/cpu):
class Mlp(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(784, 256)
self.fc2 = nn.Linear(256, 128)
self.fc3 = nn.Linear(128, 10)
def forward(self, x):
h1 = F.relu(self.fc1(x))
h2 = F.relu(self.fc2(h1))
return self.fc3(h2)
model = Mlp()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()
n = x_train.shape[0]
for epoch in range(1, EPOCHS + 1):
model.train()
t0 = time.perf_counter()
perm = torch.randperm(n)
loss_sum, batches = 0.0, 0
for start in range(0, n, BATCH_SIZE):
idx = perm[start:start + BATCH_SIZE]
xb, yb = x_train[idx], y_train[idx]
optimizer.zero_grad()
loss = loss_fn(model(xb), yb)
loss.backward()
optimizer.step()
loss_sum += loss.item()
batches += 1
PyTorch 2.15.0+cpu, threads 12
train 60000 x 784, test 10000
epoch 1 loss 0.6494 test acc 78.37% 1541 ms
epoch 2 loss 0.4836 test acc 79.15% 1930 ms
epoch 3 loss 0.4168 test acc 77.74% 1503 ms
epoch 4 loss 0.3565 test acc 79.28% 1711 ms
epoch 5 loss 0.2925 test acc 79.02% 1674 ms
reloaded model: test acc 79.02%
sample 0: predicted 0, label 0
inference 1 sample: median 146.6 us
inference 10000 samples: median 21.99 ms
peak working set 533 MB
Epoch 1 is identical to four decimals, in both loss and accuracy. That surprised me, so I checked the seed directly. manual_seed(42) produces the same random numbers and the same Linear initialization in both (PyTorch first, then TorchSharp), which fits with both seeding the same libtorch CPU generator:
rand(3) after manual_seed(42): tensor([0.8823, 0.9150, 0.3829])
first fc1 weights: tensor([ 0.0273, 0.0296, -0.0084])
rand(3) after manual_seed(42): [0.88227, 0.915, 0.38286]
first fc1 weights: [0.027305, 0.029643, -0.0083669]
From epoch 2 the runs drift in the fourth decimal and end at 78.77% vs 79.02%. My guess is float-level differences between libtorch 2.10.0 (what TorchSharp 0.107.0 ships) and PyTorch 2.15.0, but I didn’t prove that. Each side was fully repeatable on its own: 5 runs, the same final accuracy every time.
TorchSharp vs PyTorch API: What Changes
| PyTorch | TorchSharp |
|---|---|
class Mlp(nn.Module) | class Mlp : Module<Tensor, Tensor> plus RegisterComponents() |
model(xb) | model.call(xb) |
perm[start:start + BATCH_SIZE] | perm.slice(0, start, end, 1) |
x_train[idx] | xTrain.index_select(0, idx) |
loss.item() | loss.item<float>() |
with torch.no_grad(): | using (torch.no_grad()) |
torch.save(model.state_dict(), ...) | model.save(...) (different file format) |
| Garbage collector frees tensors | using var and torch.NewDisposeScope() |
The names are deliberately PyTorch’s own, snake_case included (zero_grad, index_select, manual_seed), so most PyTorch answers translate almost line by line.
Benchmark Results in Detail
The summary bench.py wrote, unedited:
startup_torchsharp: wall ms per run [1192, 504, 513, 471, 476], median 504 ms, peak WS median 103 MB
startup_pytorch: wall ms per run [1689, 1745, 1765, 1790, 1727], median 1745 ms, peak WS median 197 MB
train_torchsharp: epoch ms median 1369 (epochs 2-5: 1402), 5-epoch run wall median 8.5 s, 1-sample inference median 101.1 us, 10k-batch inference median 22.38 ms, peak WS median 679 MB, final acc [78.77]
train_pytorch: epoch ms median 1674 (epochs 2-5: 1704), 5-epoch run wall median 12.8 s, 1-sample inference median 160.8 us, 10k-batch inference median 21.28 ms, peak WS median 533 MB, final acc [79.02]
train_torchsharp_noscope: epoch ms median 1377 (epochs 2-5: 1507), 5-epoch run wall median 9.1 s, 1-sample inference median 104.5 us, 10k-batch inference median 28.18 ms, peak WS median 1022 MB, final acc [78.77]
What I take from it:
- Training speed: TorchSharp needed about 18% less time per epoch here. My read: a small MLP with batch 128 means many small native calls, where Python’s per-call overhead shows. With bigger layers the time goes into the same libtorch kernels in both, so don’t expect C# to make a large model train faster.
- Startup: 504 ms vs 1,745 ms from process start to the first tensor result; most of the Python side is starting the interpreter and importing torch. The first TorchSharp run after a build took 1,192 ms, and later runs about half a second.
- Memory: PyTorch used less during training (533 MB vs 679 MB), even though TorchSharp was lighter at startup (103 MB vs 197 MB). I fixed the obvious data-loading copy (below) but did not track down the rest of the gap.
- Batch inference: a tie (22.38 ms vs 21.28 ms for 10,000 rows).
One measurement trap: on Windows, .venv\Scripts\python.exe is a launcher that starts the real interpreter as a child process. Measured from outside, PyTorch’s peak working set was 5 MB, which is the launcher, not torch. That’s why each program reports its own peak.
Gotcha 1: TorchSharp-cpu Downloads libtorch for Every OS
TorchSharp-cpu pulls in libtorch-cpu, which references the Windows, Linux and macOS native packages. All three get restored and copied, even on a Windows-only project:
torchsharp/0.107.0 nupkg 3.2 MB extracted 13.6 MB
libtorch-cpu-win-x64/2.10.0 nupkg 76.2 MB extracted 331.7 MB
libtorch-cpu-linux-x64/2.10.0 nupkg 122.3 MB extracted 597.2 MB
libtorch-cpu-osx-arm64/2.10.0 nupkg 54.3 MB extracted 288.1 MB
total nupkg 256.0 MB extracted 1,230.8 MB
bin/Release/net10.0, no RuntimeIdentifier: 1,013.7 MB
runtimes/linux-x64: 477.2 MB
runtimes/osx-arm64: 235.8 MB
runtimes/win-x64: 265.9 MB
publish -r win-x64 --self-contained false: 268.6 MB
A 1 GB bin folder for a console app. Two fixes, both tested. Publish with a runtime identifier (268.6 MB), or reference the managed package and only the native package you need:
<PackageReference Include="TorchSharp" Version="0.107.0" />
<PackageReference Include="libtorch-cpu-win-x64" Version="2.10.0" />
libtorch packages in obj/project.assets.json:
"libtorch-cpu-win-x64/2.10.0"
bin/Release/net10.0: 305.0 MB
That restores 79.4 MB (3.2 + 76.2) instead of 256.0 MB. The TorchSharp README lists these per-platform packages and describes libtorch-cpu as the larger but simpler download. It also notes that some Windows machines need the Visual C++ redistributable; mine already had it, so I couldn’t reproduce that one. For comparison, the PyTorch CPU wheel was a 126.6 MB download and unpacked to 513.8 MB.
Gotcha 2: Tensor Memory Needs DisposeScope
A TorchSharp Tensor is a small .NET object holding native memory. The garbage collector only sees the small object, so it has no reason to hurry. With the --no-scope switch (same code, no NewDisposeScope in the batch loop), the last line of the benchmark summary above shows the same 78.77% accuracy, but a 1,022 MB peak instead of 679 MB. That’s 343 MB of tensors waiting for finalizers, for a model whose weights file is under a megabyte. The memory guide also warns that the GC knows nothing about GPU memory, so on a GPU the same habit runs you out of VRAM. The rules I follow, which match the memory guide in the TorchSharp docs: one NewDisposeScope() per batch, using var for intermediates inside forward, and never dispose a method’s input.
Gotcha 3: Loading Data Copies It, Twice
My first loader was the obvious one:
float[] x = MemoryMarshal.Cast<byte, float>(File.ReadAllBytes(prefix + "_x.bin")).ToArray();
long[] y = MemoryMarshal.Cast<byte, long>(File.ReadAllBytes(prefix + "_y.bin")).ToArray();
return (torch.tensor(x, [y.Length, 784]), torch.tensor(y));
That made a byte array, a float array and a native tensor, and peaked at 1,095 MB. Reading straight into a float[] brought it down to 885 MB. torch.tensor(float[]) copies the array (I changed the array after creating the tensor, and the tensor kept the old value), so the managed copy is pure waste. The version I kept reads the file directly into native memory:
public static (Tensor X, Tensor Y) Load(string prefix)
{
long rows = new FileInfo(prefix + "_y.bin").Length / sizeof(long);
// from_file reads straight into native memory; torch.tensor(float[]) would copy a managed array
var x = torch.from_file(prefix + "_x.bin", size: rows * 784, dtype: ScalarType.Float32).reshape(rows, 784);
var y = torch.from_file(prefix + "_y.bin", size: rows, dtype: ScalarType.Int64);
return (x, y);
}
peak working set 680 MB
The PyTorch script never had this problem: torch.from_numpy shares the NumPy buffer (the same test there showed the changed value, 99.0).
Gotcha 4: Can TorchSharp Load a PyTorch .pth File?
Not with Module.load. Pointing it at the torch.save output:
Unhandled exception. System.ArgumentException: Mismatched state_dict sizes: expected 6, but found 80 entries.
That message means “wrong file format”. It is not telling you about a model mismatch. I tested two ways that work:
1. TorchSharp’s exportsd.py. The save/load guide points to this script in the TorchSharp repo. It needs pip install leb128, which the guide doesn’t mention; the import fails without it:
ModuleNotFoundError: No module named 'leb128'
sys.path.insert(0, "interop")
import exportsd
with open("py/mlp_for_torchsharp.dat", "wb") as f:
exportsd.save_state_dict(restored.state_dict(), f)
PyTorch weights in TorchSharp: test acc 79.02%
That’s PyTorch’s exact 79.02%, computed by TorchSharp. It works because the C# field names fc1, fc2, fc3 match the Python attribute names.
2. TorchSharp.PyBridge (community package, 1.4.4, not maintained by the TorchSharp team) reads the .pth directly:
using TorchSharp.PyBridge;
// a plain PyTorch torch.save(model.state_dict(), "py/mlp.pth"), no export step
using var model = new Mlp();
model.load_py("py/mlp.pth");
mlp.pth via PyBridge load_py: test acc 79.02%
So the “train in Python, serve in C#” workflow is real, as long as you rebuild the same architecture in C#.
Inference Only? ONNX Runtime Beat Both
If C# only has to run the model, you may not need TorchSharp at all. I exported the trained PyTorch model to ONNX (after pip install onnx onnxscript):
torch.onnx.export(model, (torch.zeros(1, 784),), "py/mlp.onnx",
input_names=["input"], output_names=["logits"],
dynamic_shapes={"x": {0: torch.export.Dim("batch")}})
And ran it with Microsoft.ML.OnnxRuntime 1.31.1:
using var session = new InferenceSession("py/mlp.onnx");
float[] Run(float[] input, int count)
{
var tensor = new DenseTensor<float>(input, [count, 784]);
using var results = session.Run([NamedOnnxValue.CreateFromTensor("input", tensor)]);
return results[0].AsTensor<float>().ToArray();
}
ONNX Runtime 1.31.1.0: test acc 79.02%
inference 1 sample: median 23.1 us
inference 10000 samples: median 14.64 ms
peak working set 117 MB
Over 5 runs: 23.1 µs per sample (vs 101.1 µs in TorchSharp), 14.56 ms for the 10,000-row batch (vs 22.38 ms), 117 MB peak, and a 17.3 MB win-x64 publish folder, against 268.6 MB for TorchSharp. Same 79.02% accuracy. Two things bit me here:
- The exporter wrote the weights to a separate
mlp.onnx.datafile (940,544 bytes) next to a 6,439-bytemlp.onnx. Deploy only the.onnxand you getExternal data path does not exist: "...\py\mlp.onnx.data". - With output redirected to a file on Windows, the export crashed on the exporter’s own progress emoji:
UnicodeEncodeError: 'charmap' codec can't encode character '✅'. SettingPYTHONIOENCODING=utf-8fixed it.
ONNX Runtime also has GPU packages; I only tested the CPU one.
Gotcha 5: CS0229 With using static TorchSharp.torch
The using static TorchSharp.torch; line makes code read like PyTorch, but it also brings in nested types that clash with the ones in the TorchSharp namespace:
Program.cs(21,68): error CS0229: Ambiguity between 'TensorStringStyle' and 'torch.TensorStringStyle'
I wrote r.ToString(torch.numpy) instead of TensorStringStyle.Numpy, and it built.
PyTorch vs C#: Which One Should You Use?
Choose PyTorch (Python) when you’re researching or trying architectures, you need pretrained models and libraries built around PyTorch, you follow papers and notebooks, or you’re training on GPUs and want the tooling most people use. Nothing in my numbers argues against Python for training.
Choose C# with TorchSharp when the model is part of a .NET product: you train or fine-tune inside an ASP.NET Core service or a desktop app, your team and deployment are .NET, or you want one process instead of a Python sidecar. You get the same engine (same seed, same initialization, same first epoch in my test), a 504 ms startup, and typed C#. You pay with a large native dependency, manual tensor disposal, and a far smaller pool of examples than PyTorch.
Choose ONNX Runtime when you only need predictions in .NET. Train in PyTorch, export to ONNX, serve from C#. In this test that was the fastest and lightest option by a wide margin. For classic tabular problems (regression, classification on CSV data) you may not need deep learning at all; the ML.NET tutorial covers that route.
FAQ
Is TorchSharp the same as PyTorch? It’s a .NET binding to libtorch, the C++ library under PyTorch. With the same seed, I got identical random numbers, identical layer initialization and an identical first-epoch loss. It isn’t the Python package, so Python-only libraries built on PyTorch don’t come with it.
Is TorchSharp slower than PyTorch? Not here: 1,369 ms vs 1,674 ms per epoch on CPU, a tie on batch inference. The heavy math runs in the same native code, so expect similar speed on large models.
Which TorchSharp package should I install? TorchSharp-cpu to get started. For a Windows-only CPU app, TorchSharp plus libtorch-cpu-win-x64 cuts the download from 256.0 MB to 79.4 MB. For NVIDIA GPUs there are TorchSharp-cuda-windows and TorchSharp-cuda-linux (0.107.0 on NuGet, not tested here).
Can I train in Python and run the model in C#? Yes. Three ways worked: exportsd.py plus Module.load, TorchSharp.PyBridge’s load_py on a plain .pth, or ONNX export plus ONNX Runtime. All three reproduced PyTorch’s 79.02% test accuracy.
Does TorchSharp work on .NET 10? Yes. TorchSharp 0.107.0 ships a net8.0 library, and it ran fine on .NET 10.0.12 with SDK 10.0.401.
Related: AI in .NET tutorials · ML.NET tutorial: your first model · Local AI with Ollama and C# · Vector databases in C# · PyTorch install guide