relu3 upper bounds#877
Conversation
|
Super useful, thanks. I read two things, please correct if wrong:
Would love to see (a) the same numbers on 16M values |
2103879 to
135a2c1
Compare
|
135a2c1 has an embedded C++ version of Any tips on how to speed this C++ up? Do we need to go to SIMD? And where should we discuss this? Yordan suggested OneNote. The benefit of keeping it here is that we can directly link commits and make comments on specific parts of them. tensor<1, double> vrelu3(ks::allocator * $alloc, tensor<1, double> t) {
auto tdata = t.data();
auto ret = tensor<1, double>::create($alloc, t.size());
auto retdata = ret.data();
for (int i = 0, ne = t.num_elements(); i != ne; ++i) {
double c$1;
double x = tdata[i];
if (x < 0.0) {
c$1 = 0.0;
} else {
if (x < 1.0) {
c$1 = x * x * x / 3.0;
} else {
c$1 = x - 2.0 / 3.0;
}
}
retdata[i] = c$1;
}
return ret;
}
tensor<1, double> sufrev_vrelu3(ks::allocator * $alloc, tensor<1, double> t, tensor<1, double> dret) {
auto tdata = t.data();
auto dretdata = dret.data();
auto ret = tensor<1, double>::create($alloc, t.size());
auto retdata = ret.data();
for (int i = 0, ne = t.num_elements(); i != ne; ++i) {
double c$1;
double x = tdata[i];
double dreti = dretdata[i];
if (x < 0.0) {
c$1 = 0.0;
} else {
if (x < 1.0) {
c$1 = x * x;
} else {
c$1 = 1.0;
}
}
retdata[i] = c$1 * dreti;
}
return ret;
} |
135a2c1 to
7acc91f
Compare
|
Excellent, thanks. Even if not faster, it's great to have it in one function. Next steps:
|
7acc91f to
6625999
Compare
|
324cd83 has problem size 1M and a benchmark with no if. Without if it is more than twice as fast and faster than PyTorch! In fact it roughly matches the upper bound by map. I'm amazed that a conditional can make so much difference. (I haven't managed to get it to work on problem size 16M yet. Some of the benchmarks are very slow and don't terminate in reasonable time. Unfortunately I can't see which ones are slow without waiting for them to terminate! I'm just selectively disabling them with educated guesses.) def vrelu3_embedded_cpp_inlined_map_no_if():
return cpp_string_to_autograd_function(
"""
namespace ks{
tensor<1, double> vrelu3(ks::allocator * $alloc, tensor<1, double> t) {
auto tdata = t.data();
auto ret = tensor<1, double>::create($alloc, t.size());
auto retdata = ret.data();
for (int i = 0, ne = t.num_elements(); i != ne; ++i) {
double x = tdata[i];
retdata[i] = x * x * x / 3.0;
}
return ret;
}
tensor<1, double> sufrev_vrelu3(ks::allocator * $alloc, tensor<1, double> t, tensor<1, double> dret) {
auto tdata = t.data();
auto dretdata = dret.data();
auto ret = tensor<1, double>::create($alloc, t.size());
auto retdata = ret.data();
for (int i = 0, ne = t.num_elements(); i != ne; ++i) {
double x = tdata[i];
double dreti = dretdata[i];
retdata[i] = x * x * dreti;
}
return ret;
}
}
""",
"vrelu3",
generate_lm=False,
) |
|
Similar results on f583a96 at problem size 16M. It is now clear that C++ with inlined map and no if is faster even than ks upper bound via map. How can omitting the if make such a difference and what can we do about it? |
|
Tangentially, @cgravill, at large problem sizes the PyTest benchmarks are very slow to run. As you can see from the below table, the forward and inference benchmarks adapt the number of rounds so that they finish within about a second each. The backwards benchmarks don't adapt. They run 1000 regardless. It seems to be to do with pedantic mode. Is there anything we can do to improve that? At large problem sizes it's painful to wait so long. |
|
@toelli-msft yes the determining how many to run is the subject of AB#19173 planned for this week. Yes, pedantic mode is problematic. As you've linked to, we resorted to using it so we could provide a setup function. Unfortunately I haven't found an api within pytest-benchmarks to auto-determine the rounds but supply such a function. The intention is to replace the rounds logic with our own, then use pedantic throughout. Short term, you can reduce the rounds... maybe in proportion to tensor size but that's perhaps a little too clever for something that needs replacing. The 1000 was very grossly empirically arrived. |
Branch prediction failures... Can you try
And most of these might require manual CSE / ANFing depending on c++ compiler. Try |
Process tangent: there is the GitHub Discussions tab which we could use. It's less ephemeral than PR comments which I think was the main concern about discussion on PRs, but it would add yet another location making search/discovery more awkward. |
|
@toelli-msft #890 is merged. If you merge that to your branch you should get dynamically calculated rounds for the backward test as well making this easier to investigate. |
|
Masking makes KS and C++ faster than PyTorch! |
|
Suggestions to try:
|
|
And possibly implement #891 |
1c69cf7 to
99114ed
Compare
|
Sadly I forgot to add the multiplication by ddr to the masked examples. After adding it C++ is slower than PyTorch again. (KS mask is still faster than PyTorch but technically incorrect, as it doesn't have the multiplication by ddr. It is not detected as incorrect as ddr is 1 in the benchmarks.) Furthermore, PyTorch is still supreme at problem size 1M. :( |
KS mask is still faster than PyTorch (and cpp mask) but technically incorrect, as it doesn't have the multiplication by ddr |
|
Having said that, using ddr in KS mask doesn't seem to make as big a difference as it does in C++ mask. KS mask is within touching distance of PyTorch. I don't know why KS mask would be faster than C++ mask! I will investigate. |
|
This is the C++ generated from KS mask typedef tensor<1, double> ty$sufrev$vrelu3_mask$aT1f;
ty$sufrev$vrelu3_mask$aT1f sufrev$vrelu3_mask$aT1f(ks::allocator * $alloc, tensor<1, double> t, tensor<1, double> dret) {
/* Lam */auto c$0 = [=](ks::allocator * $alloc, double x_ddriarg1, double x_ddriarg2) {
tuple<double,double> x_ddri = ks::make_tuple(x_ddriarg1,x_ddriarg2);
auto [x, ddri] = x_ddri;
bool c$1 = gt$aff($alloc, x, 0.0);
double c$2 = bool_to_float$ab($alloc, c$1);
bool c$3 = lte$aff($alloc, x, 1.0);
double c$4 = bool_to_float$ab($alloc, c$3);
double c$5 = mul$aff($alloc, x, x);
double c$6 = mul$aff($alloc, c$4, c$5);
bool c$7 = gt$aff($alloc, x, 1.0);
double c$8 = bool_to_float$ab($alloc, c$7);
double c$9 = mul$aff($alloc, c$8, 1.0);
double c$10 = add$aff($alloc, c$6, c$9);
double c$11 = mul$aff($alloc, c$2, c$10);
double c$12 = mul$aff($alloc, c$11, ddri);
return (c$12);
};
tensor<1, double> c$13 = map2($alloc, c$0, t, dret);
return (c$13);
}I don't know why it should be faster than the embedded C++ mask tensor<1, double> sufrev_vrelu3(ks::allocator * $alloc, tensor<1, double> t, tensor<1, double> dret) {
auto tdata = t.data();
auto dretdata = dret.data();
auto ret = tensor<1, double>::create($alloc, t.size());
auto retdata = ret.data();
for (int i = 0, ne = t.num_elements(); i != ne; ++i) {
double c$1;
double x = tdata[i];
double dreti = dretdata[i];
auto val0to1 = x * x;
auto val1up = 1.0;
c$1 = (x>0)*((x<=1)*val0to1 + (x>1)*val1up) * dreti;
retdata[i] = c$1;
}
return ret;
} |
@dcrc2 and I have tried these suggestions and nothing makes a difference. |
What -march= flags did you try? Did you find xmm in the .s? |
c7c2cd8 to
daf6178
Compare
|
It would be good to merge this version because it contains implementations of most of the things we care about, and no vestigial implementations. However, the results are completely baffling, showing I haven't yet investigated how this could happen. Perhaps I've made an error somewhere. |
|
I've just run 9d21172 and obtained the same results that I obtained before. I can think of a few explanations
Any other possibilities? |
|
Things that have changed in between that could plausibly make a difference, in order of most likely to have made a difference to least likely:
Do PyTorch experts suspect that |
@dcrc2 and I resolved the weirdness by fixing a C++ bug. The reason that the bug wasn't picked up by correctness checking is an interesting story which we will relate later. Anyway, this PR is now back on track. |
| double x = tdata[i]; | ||
| auto val0to1 = x * x * x / 3.0; | ||
| auto val1up = x - 2.0 / 3.0; | ||
| auto in0to1 = x <= 1; |
17b7069 to
96db0a5
Compare
|
This is ready for proper review. Questions to resolve
Surprising observations
|
96db0a5 to
ce5b106
Compare
I'm not really sure what the purpose of this allocation limit is. I don't see a need to prohibit any allocation size less than the size of the buffer. One useful thing that this check does is to protect against a negative-size allocation being requested. If that's it's real purpose, then I'd suggest we replace the limit with |
dcrc2
left a comment
There was a problem hiding this comment.
Thanks Tom, it would be useful to merge these embedded C++ examples, or at least some subset of them, so that I can avoid breaking them (again!) with future changes.
|
Apologies for my misleading post of assembly output earlier. It did not have EDIT: Oh, and for further avoidance of doubt, the numbers in the benchmark results above were produced with knossos-ksc/src/python/ksc/compile.py Line 266 in 6f8b054 The command lineUsing The source file
#include "knossos.h"
namespace ks{
tensor<1, double> vrelu3(ks::allocator * $alloc, tensor<1, double> t) {
auto tdata = t.data();
auto ret = tensor<1, double>::create($alloc, t.size());
auto retdata = ret.data();
for (int i = 0, ne = t.num_elements(); i != ne; ++i) {
double x = tdata[i];
auto val0to1 = x * x * x / 3.0;
auto val1up = x - 2.0 / 3.0;
auto in0to1 = x <= 1;
retdata[i] = (x>0)*(in0to1*val0to1 + (!in0to1)*val1up);
}
return ret;
}
tensor<1, double> sufrev_vrelu3(ks::allocator * $alloc, tensor<1, double> t, tensor<1, double> dret) {
auto tdata = t.data();
auto dretdata = dret.data();
auto ret = tensor<1, double>::create($alloc, t.size());
auto retdata = ret.data();
for (int i = 0, ne = t.num_elements(); i != ne; ++i) {
double x = tdata[i];
double dreti = dretdata[i];
auto val0to1 = x * x;
auto val1up = 1.0;
auto in0to1 = x <= 1;
retdata[i] = (x>0)*(in0to1*val0to1 + (!in0to1)*val1up)*dreti;
}
return ret;
}
}
namespace ks{
tensor<1, double> vrelu3_bool_to_float(ks::allocator * $alloc, tensor<1, double> t) {
auto tdata = t.data();
auto ret = tensor<1, double>::create($alloc, t.size());
auto retdata = ret.data();
for (int i = 0, ne = t.num_elements(); i != ne; ++i) {
double x = tdata[i];
auto val0to1 = x * x * x / 3.0;
auto val1up = x - 2.0 / 3.0;
auto in0to1 = x <= 1;
retdata[i] = bool_to_float$ab($alloc, x > 0)
* (bool_to_float$ab($alloc, in0to1) * val0to1
+ bool_to_float$ab($alloc, !in0to1) * val1up);
}
return ret;
}
tensor<1, double> sufrev_vrelu3_bool_to_float(ks::allocator * $alloc, tensor<1, double> t, tensor<1, double> dret) {
auto tdata = t.data();
auto dretdata = dret.data();
auto ret = tensor<1, double>::create($alloc, t.size());
auto retdata = ret.data();
for (int i = 0, ne = t.num_elements(); i != ne; ++i) {
double x = tdata[i];
double dreti = dretdata[i];
auto val0to1 = x * x;
auto val1up = 1.0;
auto in0to1 = x <= 1;
retdata[i] = bool_to_float$ab($alloc, x > 0)
* (bool_to_float$ab($alloc, in0to1) * val0to1
+ bool_to_float$ab($alloc, !in0to1) * val1up)
* dreti;
}
return ret;
}
}Assembly outputLeft |
|
2x difference is amazing. The version on the right uses |
|
@cgravill Can you comment on the impact of merging this as-is? I believe it means that all of these benchmarks will run nightly (and possibly appear on graphs). If we don't want that to happen can you suggest an alternative approach? It's important that this code is in and under CI in some form. Future work depends on it (e.g. #925). What's the right thing to do here? |
|
I think it would mean:
We have get-out though as vrelu isn't configured to run: knossos-ksc/src/bench/run-all-pytest-bench.sh Lines 4 to 6 in b5f6f0b So working on the assumption that you're not planning to adjust that as part of this work I'd say go ahead as soon as you're ready. The wider issue is that perhaps not everyone would want to be running all these benchmarks, all the time. Pytest has pretty good filtering which we might want to make use of. |
|
This is perhaps a conversation for a different time, but I think of the bits in /examples as for showing to external folks. With one of our proposed benefits that we'll take care of the complexity / C++ for you then we might want to think around progressive reveal for only the folks who want to dig. |
6f8b054 to
a013805
Compare
|
Given @cgravill's comments above I think the best thing to do is to merge this now. Subsequently we can fix up the benchmarks to configure them more to our needs. |
|
Oh, hang on, the obvious smoking gun here is that we're using |
|
So, I am still content that this is ready for merge. |
Yes, any conclusions we drew for the |



























Not for merge (probably) but for information only.I have added relu3 examples that "go as fast as they could possibly go".
map.relu3and[sufrev relu3]to just return a constant or an argument.vrelu3and[sufrev vrelu3]map them across a vector. They as fast as any ksc function could possibly be whilst usingmap.vrelu3and[sufrev vrelu3]to return an argument and are as fast as any ksc function could possibly be.(Of course 2 and 3 calculate incorrect values, but the point of them is not to be correct but just to give an upper bound on speed.) On
test_backwardswe have the followingMeannumbersThe (presumably) fastest possible correct version that uses
map("Knossos embedded checkpointed_map_handwritten_inlined_relu3") is already almost 50% slower thenPyTorch. Even the "as fast as possible" version ("Knossos embedded upper_bound_via_map-torch") is only about 20% faster thanPyTorch.I have disabled accumulation into the free variable gradients in
sufrevpass_mapsince there are no free variables in these benchmarks.C++
The C++ for "Knossos embedded checkpointed_map_handwritten_inlined_relu3" shows that it will be hard to make faster with our current C++ implementation of
map.